Skip to content
Merged
2 changes: 1 addition & 1 deletion docs/docs/waveai.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 3.4
sidebar_position: 1.5
id: "waveai"
title: "Wave AI"
---
Expand Down
55 changes: 39 additions & 16 deletions emain/emain-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { updater } from "./updater";

export type WindowOpts = {
unamePlatform: string;
isPrimaryStartupWindow?: boolean;
};

const MIN_WINDOW_WIDTH = 800;
Expand All @@ -36,6 +37,7 @@ export const waveWindowMap = new Map<string, WaveBrowserWindow>(); // waveWindow
export let focusedWaveWindow: WaveBrowserWindow = null;

let cachedClientId: string = null;
let hasCompletedFirstRelaunch = false;

async function getClientId() {
if (cachedClientId != null) {
Expand All @@ -51,6 +53,7 @@ type WindowActionQueueEntry =
op: "switchtab";
tabId: string;
setInBackend: boolean;
primaryStartupTab?: boolean;
}
| {
op: "createtab";
Expand Down Expand Up @@ -346,31 +349,35 @@ export class WaveBrowserWindow extends BaseWindow {
await this._queueActionInternal({ op: "switchworkspace", workspaceId });
}

async setActiveTab(tabId: string, setInBackend: boolean) {
console.log("setActiveTab", tabId, this.waveWindowId, this.workspaceId, setInBackend);
await this._queueActionInternal({ op: "switchtab", tabId, setInBackend });
async setActiveTab(tabId: string, setInBackend: boolean, primaryStartupTab = false) {
console.log("setActiveTab", tabId, this.waveWindowId, this.workspaceId, setInBackend, primaryStartupTab ? "(primary startup)" : "");
await this._queueActionInternal({ op: "switchtab", tabId, setInBackend, primaryStartupTab });
}

private async initializeTab(tabView: WaveTabView) {
private async initializeTab(tabView: WaveTabView, primaryStartupTab: boolean) {
const clientId = await getClientId();
await tabView.initPromise;
this.contentView.addChildView(tabView);
const initOpts = {
const initOpts: WaveInitOpts = {
tabId: tabView.waveTabId,
clientId: clientId,
windowId: this.waveWindowId,
activate: true,
};
if (primaryStartupTab) {
initOpts.primaryTabStartup = true;
}
tabView.savedInitOpts = { ...initOpts };
tabView.savedInitOpts.activate = false;
delete tabView.savedInitOpts.primaryTabStartup;
let startTime = Date.now();
console.log("before wave ready, init tab, sending wave-init", tabView.waveTabId);
console.log("before wave ready, init tab, sending wave-init", tabView.waveTabId, primaryStartupTab ? "(primary startup)" : "");
tabView.webContents.send("wave-init", initOpts);
await tabView.waveReadyPromise;
console.log("wave-ready init time", Date.now() - startTime + "ms");
}

private async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean) {
private async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean, primaryStartupTab = false) {
if (this.activeTabView == tabView) {
return;
}
Expand All @@ -382,8 +389,8 @@ export class WaveBrowserWindow extends BaseWindow {
this.activeTabView = tabView;
this.allLoadedTabViews.set(tabView.waveTabId, tabView);
if (!tabInitialized) {
console.log("initializing a new tab");
const p1 = this.initializeTab(tabView);
console.log("initializing a new tab", primaryStartupTab ? "(primary startup)" : "");
const p1 = this.initializeTab(tabView, primaryStartupTab);
const p2 = this.repositionTabsSlowly(100);
await Promise.all([p1, p2]);
} else {
Expand Down Expand Up @@ -541,7 +548,8 @@ export class WaveBrowserWindow extends BaseWindow {
return;
}
const [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId);
await this.setTabViewIntoWindow(tabView, tabInitialized);
const primaryStartupTabFlag = entry.op === "switchtab" ? entry.primaryStartupTab ?? false : false;
await this.setTabViewIntoWindow(tabView, tabInitialized, primaryStartupTabFlag);
} catch (e) {
console.log("error caught in processActionQueue", e);
} finally {
Expand Down Expand Up @@ -628,6 +636,7 @@ export async function createWindowForWorkspace(workspaceId: string) {
}
const newBwin = await createBrowserWindow(newWin, await RpcApi.GetFullConfigCommand(ElectronWshClient), {
unamePlatform,
isPrimaryStartupWindow: false,
});
newBwin.show();
}
Expand All @@ -653,7 +662,7 @@ export async function createBrowserWindow(
console.log("createBrowserWindow", waveWindow.oid, workspace.oid, workspace);
const bwin = new WaveBrowserWindow(waveWindow, fullConfig, opts);
if (workspace.activetabid) {
await bwin.setActiveTab(workspace.activetabid, false);
await bwin.setActiveTab(workspace.activetabid, false, opts.isPrimaryStartupWindow ?? false);
}
return bwin;
}
Expand Down Expand Up @@ -764,7 +773,10 @@ export async function createNewWaveWindow() {
const existingWindowId = clientData.windowids[0];
const existingWindowData = (await ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow;
if (existingWindowData != null) {
const win = await createBrowserWindow(existingWindowData, fullConfig, { unamePlatform });
const win = await createBrowserWindow(existingWindowData, fullConfig, {
unamePlatform,
isPrimaryStartupWindow: false,
});
win.show();
recreatedWindow = true;
}
Expand All @@ -774,7 +786,10 @@ export async function createNewWaveWindow() {
return;
}
console.log("creating new window");
const newBrowserWindow = await createBrowserWindow(null, fullConfig, { unamePlatform });
const newBrowserWindow = await createBrowserWindow(null, fullConfig, {
unamePlatform,
isPrimaryStartupWindow: false,
});
newBrowserWindow.show();
}

Expand All @@ -793,18 +808,26 @@ export async function relaunchBrowserWindows() {

const clientData = await ClientService.GetClientData();
const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient);
const windowIds = clientData.windowids ?? [];
const wins: WaveBrowserWindow[] = [];
for (const windowId of clientData.windowids.slice().reverse()) {
const isFirstRelaunch = !hasCompletedFirstRelaunch;
const primaryWindowId = windowIds.length > 0 ? windowIds[0] : null;
for (const windowId of windowIds.slice().reverse()) {
const windowData: WaveWindow = await WindowService.GetWindow(windowId);
if (windowData == null) {
console.log("relaunch -- window data not found, closing window", windowId);
await WindowService.CloseWindow(windowId, true);
continue;
}
console.log("relaunch -- creating window", windowId, windowData);
const win = await createBrowserWindow(windowData, fullConfig, { unamePlatform });
const isPrimaryStartupWindow = isFirstRelaunch && windowId === primaryWindowId;
console.log("relaunch -- creating window", windowId, windowData, isPrimaryStartupWindow ? "(primary startup)" : "");
const win = await createBrowserWindow(windowData, fullConfig, {
unamePlatform,
isPrimaryStartupWindow
});
wins.push(win);
Comment on lines +811 to 828
Copy link
Contributor

@coderabbitai coderabbitai bot Oct 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Guard against null clientData before reading windowids.

ClientService.GetClientData() can return null/undefined during startup or error scenarios, so clientData.windowids will throw and abort the relaunch. Please restore the null-guard when building the window list.

-    const windowIds = clientData.windowids ?? [];
+    const windowIds = clientData?.windowids ?? [];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const windowIds = clientData.windowids ?? [];
const wins: WaveBrowserWindow[] = [];
for (const windowId of clientData.windowids.slice().reverse()) {
const isFirstRelaunch = !hasCompletedFirstRelaunch;
const primaryWindowId = windowIds.length > 0 ? windowIds[0] : null;
for (const windowId of windowIds.slice().reverse()) {
const windowData: WaveWindow = await WindowService.GetWindow(windowId);
if (windowData == null) {
console.log("relaunch -- window data not found, closing window", windowId);
await WindowService.CloseWindow(windowId, true);
continue;
}
console.log("relaunch -- creating window", windowId, windowData);
const win = await createBrowserWindow(windowData, fullConfig, { unamePlatform });
const isPrimaryStartupWindow = isFirstRelaunch && windowId === primaryWindowId;
console.log("relaunch -- creating window", windowId, windowData, isPrimaryStartupWindow ? "(primary startup)" : "");
const win = await createBrowserWindow(windowData, fullConfig, {
unamePlatform,
isPrimaryStartupWindow
});
wins.push(win);
const windowIds = clientData?.windowids ?? [];
const wins: WaveBrowserWindow[] = [];
const isFirstRelaunch = !hasCompletedFirstRelaunch;
const primaryWindowId = windowIds.length > 0 ? windowIds[0] : null;
for (const windowId of windowIds.slice().reverse()) {
const windowData: WaveWindow = await WindowService.GetWindow(windowId);
if (windowData == null) {
console.log("relaunch -- window data not found, closing window", windowId);
await WindowService.CloseWindow(windowId, true);
continue;
}
const isPrimaryStartupWindow = isFirstRelaunch && windowId === primaryWindowId;
console.log("relaunch -- creating window", windowId, windowData, isPrimaryStartupWindow ? "(primary startup)" : "");
const win = await createBrowserWindow(windowData, fullConfig, {
unamePlatform,
isPrimaryStartupWindow
});
wins.push(win);
🤖 Prompt for AI Agents
In emain/emain-window.ts around lines 811 to 828, the code reads
clientData.windowids which will throw if clientData is null/undefined; restore a
null-guard by either checking clientData first (if (!clientData) { log and
abort/return or set windowIds = []; }) or use optional chaining (const windowIds
= clientData?.windowids ?? []), ensuring you handle the null case consistently
(log the condition and avoid proceeding with relaunch when clientData is
missing).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null client data is a failure. nothing to do and it should be impossible as the backend will initialize a client before startup.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sawka, understood! If the backend guarantees client initialization before this code path executes, then the null-guard is indeed unnecessary. Thank you for the clarification on the startup sequence.


✏️ Learnings added
Learnt from: sawka
PR: wavetermdev/waveterm#2433
File: emain/emain-window.ts:811-828
Timestamp: 2025-10-15T03:18:52.624Z
Learning: In emain/emain-window.ts, within the relaunchBrowserWindows function, ClientService.GetClientData() is guaranteed to return a valid client object and never null/undefined. The backend ensures a client is initialized before startup, so no null-guard is needed when accessing clientData.windowids.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

}
hasCompletedFirstRelaunch = true;
for (const win of wins) {
console.log("show window", win.waveWindowId);
win.show();
Expand Down
5 changes: 4 additions & 1 deletion emain/emain-wsh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export class ElectronWshClientType extends WshClient {
if (window == null) {
throw new Error(`window ${windowId} not found`);
}
ww = await createBrowserWindow(window, fullConfig, { unamePlatform });
ww = await createBrowserWindow(window, fullConfig, {
unamePlatform,
isPrimaryStartupWindow: false,
});
}
ww.focus();
}
Expand Down
5 changes: 4 additions & 1 deletion emain/emain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ function handleWSEvent(evtMsg: WSEventType) {
return;
}
const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient);
const newWin = await createBrowserWindow(windowData, fullConfig, { unamePlatform });
const newWin = await createBrowserWindow(windowData, fullConfig, {
unamePlatform,
isPrimaryStartupWindow: false,
});
newWin.show();
} else if (evtMsg.eventtype == "electron:closewindow") {
console.log("electron:closewindow", evtMsg.data);
Expand Down
4 changes: 4 additions & 0 deletions frontend/app/aipanel/telemetryrequired.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { cn } from "@/util/util";
import { useState } from "react";
import { WaveAIModel } from "./waveai-model";

interface TelemetryRequiredMessageProps {
className?: string;
Expand All @@ -17,6 +18,9 @@ const TelemetryRequiredMessage = ({ className }: TelemetryRequiredMessageProps)
setIsEnabling(true);
try {
await RpcApi.WaveAIEnableTelemetryCommand(TabRpcClient);
setTimeout(() => {
WaveAIModel.getInstance().focusInput();
}, 100);
} catch (error) {
console.error("Failed to enable telemetry:", error);
setIsEnabling(false);
Expand Down
6 changes: 4 additions & 2 deletions frontend/app/modals/modalregistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
// SPDX-License-Identifier: Apache-2.0

import { MessageModal } from "@/app/modals/messagemodal";
import { OnboardingModal } from "@/app/onboarding/onboarding";
import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding";
import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade";
import { AboutModal } from "./about";
import { UserInputModal } from "./userinputmodal";

const modalRegistry: { [key: string]: React.ComponentType<any> } = {
[OnboardingModal.displayName || "OnboardingModal"]: OnboardingModal,
[NewInstallOnboardingModal.displayName || "NewInstallOnboardingModal"]: NewInstallOnboardingModal,
[UpgradeOnboardingModal.displayName || "UpgradeOnboardingModal"]: UpgradeOnboardingModal,
[UserInputModal.displayName || "UserInputModal"]: UserInputModal,
[AboutModal.displayName || "AboutModal"]: AboutModal,
[MessageModal.displayName || "MessageModal"]: MessageModal,
Expand Down
32 changes: 26 additions & 6 deletions frontend/app/modals/modalsrenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { OnboardingModal } from "@/app/onboarding/onboarding";
import { atoms, globalStore } from "@/store/global";
import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding";
import { CurrentOnboardingVersion } from "@/app/onboarding/onboarding-features";
import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade";
import { atoms, globalPrimaryTabStartup, globalStore } from "@/store/global";
import { modalsModel } from "@/store/modalmodel";
import * as jotai from "jotai";
import { useEffect } from "react";
import semver from "semver";
import { getModalComponent } from "./modalregistry";

const ModalsRenderer = () => {
const clientData = jotai.useAtomValue(atoms.client);
const [tosOpen, setTosOpen] = jotai.useAtom(modalsModel.tosOpen);
const [newInstallOnboardingOpen, setNewInstallOnboardingOpen] = jotai.useAtom(modalsModel.newInstallOnboardingOpen);
const [upgradeOnboardingOpen, setUpgradeOnboardingOpen] = jotai.useAtom(modalsModel.upgradeOnboardingOpen);
const [modals] = jotai.useAtom(modalsModel.modalsAtom);
const rtn: React.ReactElement[] = [];
for (const modal of modals) {
Expand All @@ -19,14 +23,30 @@ const ModalsRenderer = () => {
rtn.push(<ModalComponent key={modal.displayName} {...modal.props} />);
}
}
if (tosOpen) {
rtn.push(<OnboardingModal key={OnboardingModal.displayName} />);
if (newInstallOnboardingOpen) {
rtn.push(<NewInstallOnboardingModal key={NewInstallOnboardingModal.displayName} />);
}
if (upgradeOnboardingOpen) {
rtn.push(<UpgradeOnboardingModal key={UpgradeOnboardingModal.displayName} />);
}
useEffect(() => {
if (!clientData.tosagreed) {
setTosOpen(true);
setNewInstallOnboardingOpen(true);
}
}, [clientData]);

useEffect(() => {
if (!globalPrimaryTabStartup) {
return;
}
if (!clientData.tosagreed) {
return;
}
const lastVersion = clientData.meta?.["onboarding:lastversion"] ?? "v0.0.0";
if (semver.lt(lastVersion, CurrentOnboardingVersion)) {
setUpgradeOnboardingOpen(true);
}
}, []);
Comment on lines +38 to +49
Copy link
Contributor

@coderabbitai coderabbitai bot Oct 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Upgrade modal never opens with current effect dependencies

The upgrade gating effect runs only once on mount (empty deps), so it observes the initial globalPrimaryTabStartup (usually false) and preloaded clientData (often missing meta). The guard exits early and never re-evaluates when startup flips true or when real client metadata arrives, so existing users will never see the upgrade modal—the core feature of this PR. Please include the relevant dependencies (and, if necessary, coerce the version strings) so the effect re-checks once the startup flag and metadata are ready before opening the modal.

🤖 Prompt for AI Agents
In frontend/app/modals/modalsrenderer.tsx around lines 38–49, the useEffect
currently has an empty dependency array so it never re-runs when
globalPrimaryTabStartup or clientData (tosagreed or meta) update; update the
effect to depend on [globalPrimaryTabStartup, clientData?.tosagreed,
clientData?.meta?.["onboarding:lastversion"]] and move the early-return guards
inside so the effect re-checks whenever startup flips or metadata arrives;
coerce the stored version with a fallback like
clientData.meta?.["onboarding:lastversion"] ?? "v0.0.0" (or otherwise normalize
to a string) before calling semver.lt(CurrentOnboardingVersion) and only call
setUpgradeOnboardingOpen(true) when semver.lt(lastVersion,
CurrentOnboardingVersion) and the modal isn’t already open.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

globalPrimaryTabStartup is set by the waveInit function which runs before the first react render (in wave.ts).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the initialization flow in wave.ts to verify when waveInit is called
# and how globalPrimaryTabStartup is set

# First, let's look at wave.ts to see the waveInit function and when it's called
cat frontend/wave.ts

Length of output: 7756


🏁 Script executed:

#!/bin/bash
# Check how clientData/atoms.client is populated and if meta is available on initial load
# Look at the WOS loading and client structure

# Check the client loading in initWave
rg -n -C3 "loadAndPinWaveObject.*client" frontend/wave.ts

# Check if there's any async meta loading after client is loaded
rg -n -A5 -B5 "onboarding:lastversion" frontend/

# Check the Client type definition to see if meta is part of the initial load
fd -e ts -e tsx | xargs rg -l "interface Client|type Client" | head -5

Length of output: 2966


@sawka Thank you for the clarification! You're absolutely right. Looking at the initialization flow in wave.ts, I can see that:

  1. initGlobal is called with primaryTabStartup: initOpts.primaryTabStartup before React renders
  2. Client data (including meta) is loaded via WOS.loadAndPinWaveObject<Client> and awaited before React renders
  3. React renders only after both these steps complete

So by the time the useEffect runs (after component mount), both globalPrimaryTabStartup and clientData.meta already have their final values. The empty dependency array [] is correct here because the effect is meant to run exactly once on mount of the primary startup tab to check if the upgrade modal should be shown.

I apologize for the incorrect analysis. The initialization sequence ensures the values are ready, and the empty deps array prevents unnecessary re-execution. The implementation is sound.

 /\_/\
( o.o )
 > ^ <  "Got it!"

🧬 Code Graph Analysis Results

<no_relevant_code_snippets>

useEffect(() => {
globalStore.set(atoms.modalOpen, rtn.length > 0);
}, [rtn]);
Expand Down
16 changes: 15 additions & 1 deletion frontend/app/onboarding/onboarding-features.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import Logo from "@/app/asset/logo.svg";
import { Button } from "@/app/element/button";
import { EmojiButton } from "@/app/element/emojibutton";
import { MagnifyIcon } from "@/app/element/magnify";
import { atoms, globalStore } from "@/app/store/global";
import * as WOS from "@/app/store/wos";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { isMacOS } from "@/util/platformutil";
Expand All @@ -13,6 +15,8 @@ import { FakeChat } from "./fakechat";
import { EditBashrcCommand, ViewLogoCommand, ViewShortcutsCommand } from "./onboarding-command";
import { FakeLayout } from "./onboarding-layout";

export const CurrentOnboardingVersion = "v0.12.0";

type FeaturePageName = "waveai" | "magnify" | "files";

const OnboardingFooter = ({
Expand Down Expand Up @@ -72,6 +76,7 @@ const WaveAIPage = ({ onNext, onSkip }: { onNext: () => void; onSkip: () => void
event: "onboarding:fire",
props: {
"onboarding:feature": "waveai",
"onboarding:version": CurrentOnboardingVersion,
},
});
}
Expand Down Expand Up @@ -158,6 +163,7 @@ const MagnifyBlocksPage = ({
event: "onboarding:fire",
props: {
"onboarding:feature": "magnify",
"onboarding:version": CurrentOnboardingVersion,
},
});
}
Expand Down Expand Up @@ -216,6 +222,7 @@ const FilesPage = ({ onFinish, onPrev }: { onFinish: () => void; onPrev?: () =>
event: "onboarding:fire",
props: {
"onboarding:feature": "wsh",
"onboarding:version": CurrentOnboardingVersion,
},
});
}
Expand Down Expand Up @@ -300,9 +307,16 @@ export const OnboardingFeatures = ({ onComplete }: { onComplete: () => void }) =
const [currentPage, setCurrentPage] = useState<FeaturePageName>("waveai");

useEffect(() => {
const clientId = globalStore.get(atoms.clientId);
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("client", clientId),
meta: { "onboarding:lastversion": CurrentOnboardingVersion },
});
RpcApi.RecordTEventCommand(TabRpcClient, {
event: "onboarding:start",
props: {},
props: {
"onboarding:version": CurrentOnboardingVersion,
},
});
}, []);

Expand Down
Loading
Loading