TabBar full preview + much more FE mocking via WaveEnv to enable it (#3028)

Large PR that extends WaveEnv mocking to fully cover the (complicated) TabBar implementation.  Also includes a full preview of the tab bar in the preview server with lots of controls to simulate different scenarios.

As a result of this mocking, also fixed a bunch of dependencies, and layout errors, random bugs, and visual UX bugs in the tab bar, making it more robust.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
Co-authored-by: sawka <mike@commandline.dev>
This commit is contained in:
Copilot 2026-03-11 13:54:12 -07:00 committed by GitHub
parent 568027df21
commit ecccad6ea1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1056 additions and 373 deletions

View file

@ -26,7 +26,7 @@ RPC commands in Wave Terminal follow these conventions:
- **Method names** must end with `Command`
- **First parameter** must be `context.Context`
- **Second parameter** (optional) is the command data structure
- **Remaining parameters** are a regular Go parameter list (zero or more typed args)
- **Return values** can be either just an error, or one return value plus an error
- **Streaming commands** return a channel instead of a direct value
@ -49,7 +49,7 @@ type WshRpcInterface interface {
- Method name must end with `Command`
- First parameter must be `ctx context.Context`
- Optional second parameter for input data
- Remaining parameters are a regular Go parameter list (zero or more)
- Return either `error` or `(ReturnType, error)`
- For streaming, return `chan RespOrErrorUnion[T]`

View file

@ -69,6 +69,12 @@ export type MyEnv = WaveEnvSubset<{
// --- wos: always take the whole thing, no sub-typing needed ---
wos: WaveEnv["wos"];
// --- services: list only the services you call; no method-level narrowing ---
services: {
block: WaveEnv["services"]["block"];
workspace: WaveEnv["services"]["workspace"];
};
// --- key-parameterized atom factories: enumerate the keys you use ---
getSettingsKeyAtom: SettingsKeyAtomFnType<"app:focusfollowscursor" | "window:magnifiedblockopacity">;
getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"view" | "frame:title" | "connection">;
@ -80,6 +86,14 @@ export type MyEnv = WaveEnvSubset<{
}>;
```
### Automatically Included Fields
Every `WaveEnvSubset<T>` automatically includes the mock fields — you never need to declare them:
- `isMock: boolean`
- `mockSetWaveObj: <T extends WaveObj>(oref: string, obj: T) => void`
- `mockModels?: Map<any, any>`
### Rules for Each Section
| Section | Pattern | Notes |
@ -88,6 +102,7 @@ export type MyEnv = WaveEnvSubset<{
| `rpc` | `rpc: { Cmd: WaveEnv["rpc"]["Cmd"]; }` | List every RPC command called; omit the rest. |
| `atoms` | `atoms: { atom: WaveEnv["atoms"]["atom"]; }` | List every atom read; omit the rest. |
| `wos` | `wos: WaveEnv["wos"]` | Take the whole `wos` object (no sub-typing needed), but **only add it if `wos` is actually used**. |
| `services` | `services: { svc: WaveEnv["services"]["svc"]; }` | List each service used; take the whole service object (no method-level narrowing). |
| `getSettingsKeyAtom` | `SettingsKeyAtomFnType<"key1" \| "key2">` | Union all settings keys accessed. |
| `getBlockMetaKeyAtom` | `BlockMetaKeyAtomFnType<"key1" \| "key2">` | Union all block meta keys accessed. |
| `getConnConfigKeyAtom` | `ConnConfigKeyAtomFnType<"key1">` | Union all conn config keys accessed. |

View file

@ -88,7 +88,14 @@ func generateServicesFile(tsTypesMap map[reflect.Type]string) error {
fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n")
fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n")
fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n")
fmt.Fprintf(&buf, "import * as WOS from \"./wos\";\n\n")
fmt.Fprintf(&buf, "import * as WOS from \"./wos\";\n")
fmt.Fprintf(&buf, "import type { WaveEnv } from \"@/app/waveenv/waveenv\";\n\n")
fmt.Fprintf(&buf, "function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise<any> {\n")
fmt.Fprintf(&buf, " if (waveEnv != null) {\n")
fmt.Fprintf(&buf, " return waveEnv.callBackendService(service, method, args, noUIContext)\n")
fmt.Fprintf(&buf, " }\n")
fmt.Fprintf(&buf, " return WOS.callBackendService(service, method, args, noUIContext);\n")
fmt.Fprintf(&buf, "}\n\n")
orderedKeys := utilfn.GetOrderedMapKeys(service.ServiceMap)
for _, serviceName := range orderedKeys {
serviceObj := service.ServiceMap[serviceName]
@ -96,6 +103,22 @@ func generateServicesFile(tsTypesMap map[reflect.Type]string) error {
fmt.Fprint(&buf, svcStr)
fmt.Fprint(&buf, "\n")
}
fmt.Fprintf(&buf, "export const AllServiceTypes = {\n")
for _, serviceName := range orderedKeys {
serviceObj := service.ServiceMap[serviceName]
serviceType := reflect.TypeOf(serviceObj)
tsServiceName := serviceType.Elem().Name()
fmt.Fprintf(&buf, " %q: %sType,\n", serviceName, tsServiceName)
}
fmt.Fprintf(&buf, "};\n\n")
fmt.Fprintf(&buf, "export const AllServiceImpls = {\n")
for _, serviceName := range orderedKeys {
serviceObj := service.ServiceMap[serviceName]
serviceType := reflect.TypeOf(serviceObj)
tsServiceName := serviceType.Elem().Name()
fmt.Fprintf(&buf, " %q: %s,\n", serviceName, tsServiceName)
}
fmt.Fprintf(&buf, "};\n")
written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes())
if !written {
fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName)

View file

@ -89,6 +89,7 @@ export default [
{
files: ["frontend/app/store/services.ts"],
rules: {
"@typescript-eslint/no-unused-vars": "off",
"prefer-rest-params": "off",
},
},

View file

@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { CopyButton } from "@/app/element/copybutton";
@ -314,11 +314,12 @@ export const WaveStreamdown = ({
table: false,
mermaid: true,
}}
mermaidConfig={{
theme: "dark",
darkMode: true,
mermaid={{
config: {
theme: "dark",
darkMode: true,
},
}}
defaultOrigin="http://localhost"
components={components}
>
{text}

View file

@ -3,6 +3,7 @@
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
import { fireAndForget, NullAtom } from "@/util/util";
import { atom, Atom, PrimitiveAtom } from "jotai";
import { v7 as uuidv7, version as uuidVersion } from "uuid";
@ -10,10 +11,34 @@ import { globalStore } from "./jotaiStore";
import * as WOS from "./wos";
import { waveEventSubscribeSingle } from "./wps";
export type BadgeEnv = WaveEnvSubset<{
rpc: {
EventPublishCommand: WaveEnv["rpc"]["EventPublishCommand"];
};
}>;
export type LoadBadgesEnv = WaveEnvSubset<{
rpc: {
GetAllBadgesCommand: WaveEnv["rpc"]["GetAllBadgesCommand"];
};
}>;
export type TabBadgesEnv = WaveEnvSubset<{
wos: WaveEnv["wos"];
}>;
const BadgeMap = new Map<string, PrimitiveAtom<Badge>>();
const TabBadgeAtomCache = new Map<string, Atom<Badge[]>>();
function clearBadgeInternal(oref: string) {
function publishBadgeEvent(eventData: WaveEvent, env?: BadgeEnv) {
if (env != null) {
fireAndForget(() => env.rpc.EventPublishCommand(TabRpcClient, eventData));
} else {
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
}
}
function clearBadgeInternal(oref: string, env?: BadgeEnv) {
const eventData: WaveEvent = {
event: "badge",
scopes: [oref],
@ -22,28 +47,28 @@ function clearBadgeInternal(oref: string) {
clear: true,
} as BadgeEvent,
};
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
publishBadgeEvent(eventData, env);
}
function clearBadgesForBlockOnFocus(blockId: string) {
function clearBadgesForBlockOnFocus(blockId: string, env?: BadgeEnv) {
const oref = WOS.makeORef("block", blockId);
const badgeAtom = BadgeMap.get(oref);
const badge = badgeAtom != null ? globalStore.get(badgeAtom) : null;
if (badge != null && !badge.pidlinked) {
clearBadgeInternal(oref);
clearBadgeInternal(oref, env);
}
}
function clearBadgesForTabOnFocus(tabId: string) {
function clearBadgesForTabOnFocus(tabId: string, env?: BadgeEnv) {
const oref = WOS.makeORef("tab", tabId);
const badgeAtom = BadgeMap.get(oref);
const badge = badgeAtom != null ? globalStore.get(badgeAtom) : null;
if (badge != null && !badge.pidlinked) {
clearBadgeInternal(oref);
clearBadgeInternal(oref, env);
}
}
function clearAllBadges() {
function clearAllBadges(env?: BadgeEnv) {
const eventData: WaveEvent = {
event: "badge",
scopes: [],
@ -52,10 +77,10 @@ function clearAllBadges() {
clearall: true,
} as BadgeEvent,
};
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
publishBadgeEvent(eventData, env);
}
function clearBadgesForTab(tabId: string) {
function clearBadgesForTab(tabId: string, env?: BadgeEnv) {
const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId));
const tab = globalStore.get(tabAtom);
const blockIds = (tab as Tab)?.blockids ?? [];
@ -63,7 +88,7 @@ function clearBadgesForTab(tabId: string) {
const oref = WOS.makeORef("block", blockId);
const badgeAtom = BadgeMap.get(oref);
if (badgeAtom != null && globalStore.get(badgeAtom) != null) {
clearBadgeInternal(oref);
clearBadgeInternal(oref, env);
}
}
}
@ -88,7 +113,7 @@ function getBlockBadgeAtom(blockId: string): Atom<Badge> {
return getBadgeAtom(oref);
}
function getTabBadgeAtom(tabId: string): Atom<Badge[]> {
function getTabBadgeAtom(tabId: string, env?: TabBadgesEnv): Atom<Badge[]> {
if (tabId == null) {
return NullAtom as Atom<Badge[]>;
}
@ -98,7 +123,8 @@ function getTabBadgeAtom(tabId: string): Atom<Badge[]> {
}
const tabOref = WOS.makeORef("tab", tabId);
const tabBadgeAtom = getBadgeAtom(tabOref);
const tabAtom = atom((get) => WOS.getObjectValue<Tab>(tabOref, get));
const tabAtom =
env != null ? env.wos.getWaveObjectAtom<Tab>(tabOref) : WOS.getWaveObjectAtom<Tab>(tabOref);
rtn = atom((get) => {
const tab = get(tabAtom);
const blockIds = tab?.blockids ?? [];
@ -119,8 +145,9 @@ function getTabBadgeAtom(tabId: string): Atom<Badge[]> {
return rtn;
}
async function loadBadges() {
const badges = await RpcApi.GetAllBadgesCommand(TabRpcClient);
async function loadBadges(env?: LoadBadgesEnv) {
const rpc = env != null ? env.rpc : RpcApi;
const badges = await rpc.GetAllBadgesCommand(TabRpcClient);
if (badges == null) {
return;
}
@ -133,7 +160,7 @@ async function loadBadges() {
}
}
function setBadge(blockId: string, badge: Omit<Badge, "badgeid"> & { badgeid?: string }) {
function setBadge(blockId: string, badge: Omit<Badge, "badgeid"> & { badgeid?: string }, env?: BadgeEnv) {
if (!badge.badgeid) {
badge = { ...badge, badgeid: uuidv7() };
} else if (uuidVersion(badge.badgeid) !== 7) {
@ -148,10 +175,10 @@ function setBadge(blockId: string, badge: Omit<Badge, "badgeid"> & { badgeid?: s
badge: badge,
} as BadgeEvent,
};
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
publishBadgeEvent(eventData, env);
}
function clearBadgeById(blockId: string, badgeId: string) {
function clearBadgeById(blockId: string, badgeId: string, env?: BadgeEnv) {
const oref = WOS.makeORef("block", blockId);
const eventData: WaveEvent = {
event: "badge",
@ -161,7 +188,7 @@ function clearBadgeById(blockId: string, badgeId: string) {
clearbyid: badgeId,
} as BadgeEvent,
};
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
publishBadgeEvent(eventData, env);
}
function setupBadgesSubscription() {

View file

@ -74,11 +74,11 @@ class ContextMenuModel {
this.activeOpts = opts;
const electronMenuItems = this._convertAndRegisterMenu(menu);
const workspace = globalStore.get(atoms.workspace);
const workspaceId = globalStore.get(atoms.workspaceId);
let oid: string;
if (workspace != null) {
oid = workspace.oid;
if (workspaceId != null) {
oid = workspaceId;
} else {
oid = globalStore.get(atoms.builderId);
}

View file

@ -43,12 +43,16 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
console.log("failed to initialize zoomFactorAtom", e);
}
const workspaceAtom: Atom<Workspace> = atom((get) => {
const workspaceIdAtom: Atom<string> = atom((get) => {
const windowData = WOS.getObjectValue<WaveWindow>(WOS.makeORef("window", get(windowIdAtom)), get);
if (windowData == null) {
return windowData?.workspaceid ?? null;
});
const workspaceAtom: Atom<Workspace> = atom((get) => {
const workspaceId = get(workspaceIdAtom);
if (workspaceId == null) {
return null;
}
return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get);
return WOS.getObjectValue(WOS.makeORef("workspace", workspaceId), get);
});
const fullConfigAtom = atom(null) as PrimitiveAtom<FullConfigType>;
const waveaiModeConfigAtom = atom(null) as PrimitiveAtom<Record<string, AIModeConfigType>>;
@ -67,6 +71,10 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
}
return false;
}) as Atom<boolean>;
const hasConfigErrors = atom((get) => {
const fullConfig = get(fullConfigAtom);
return fullConfig?.configerrors != null && fullConfig.configerrors.length > 0;
}) as Atom<boolean>;
// this is *the* tab that this tabview represents. it should never change.
const staticTabIdAtom: Atom<string> = atom(initOpts.tabId);
const controlShiftDelayAtom = atom(false);
@ -123,11 +131,13 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
builderId: builderIdAtom,
builderAppId: builderAppIdAtom,
uiContext: uiContextAtom,
workspaceId: workspaceIdAtom,
workspace: workspaceAtom,
fullConfigAtom,
waveaiModeConfigAtom,
settingsAtom,
hasCustomAIPresetsAtom,
hasConfigErrors,
staticTabId: staticTabIdAtom,
isFullScreen: isFullScreenAtom,
zoomFactorAtom,

View file

@ -547,6 +547,7 @@ function getAllBlockComponentModels(): BlockComponentModel[] {
function getFocusedBlockId(): string {
const layoutModel = getLayoutModelForStaticTab();
if (layoutModel?.focusedNode == null) return null;
const focusedLayoutNode = globalStore.get(layoutModel.focusedNode);
return focusedLayoutNode?.data?.blockId;
}

View file

@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { WaveAIModel } from "@/app/aipanel/waveai-model";
@ -129,11 +129,11 @@ function getStaticTabBlockCount(): number {
}
function simpleCloseStaticTab() {
const ws = globalStore.get(atoms.workspace);
const workspaceId = globalStore.get(atoms.workspaceId);
const tabId = globalStore.get(atoms.staticTabId);
const confirmClose = globalStore.get(getSettingsKeyAtom("tab:confirmclose")) ?? false;
getApi()
.closeTab(ws.oid, tabId, confirmClose)
.closeTab(workspaceId, tabId, confirmClose)
.then((didClose) => {
if (didClose) {
deleteLayoutModelForTab(tabId);
@ -490,7 +490,7 @@ function tryReinjectKey(event: WaveKeyboardEvent): boolean {
function countTermBlocks(): number {
const allBCMs = getAllBlockComponentModels();
let count = 0;
let gsGetBound = globalStore.get.bind(globalStore);
const gsGetBound = globalStore.get.bind(globalStore);
for (const bcm of allBCMs) {
const viewModel = bcm.viewModel;
if (viewModel.viewType == "term" && viewModel.isBasicTerm?.(gsGetBound)) {

View file

@ -4,182 +4,233 @@
// generated by cmd/generate/main-generatets.go
import * as WOS from "./wos";
import type { WaveEnv } from "@/app/waveenv/waveenv";
function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise<any> {
if (waveEnv != null) {
return waveEnv.callBackendService(service, method, args, noUIContext)
}
return WOS.callBackendService(service, method, args, noUIContext);
}
// blockservice.BlockService (block)
class BlockServiceType {
export class BlockServiceType {
waveEnv: WaveEnv;
constructor(waveEnv?: WaveEnv) {
this.waveEnv = waveEnv;
}
// queue a layout action to cleanup orphaned blocks in the tab
// @returns object updates
CleanupOrphanedBlocks(tabId: string): Promise<void> {
return WOS.callBackendService("block", "CleanupOrphanedBlocks", Array.from(arguments))
return callBackendService(this.waveEnv, "block", "CleanupOrphanedBlocks", Array.from(arguments))
}
GetControllerStatus(arg2: string): Promise<BlockControllerRuntimeStatus> {
return WOS.callBackendService("block", "GetControllerStatus", Array.from(arguments))
return callBackendService(this.waveEnv, "block", "GetControllerStatus", Array.from(arguments))
}
// save the terminal state to a blockfile
SaveTerminalState(blockId: string, state: string, stateType: string, ptyOffset: number, termSize: TermSize): Promise<void> {
return WOS.callBackendService("block", "SaveTerminalState", Array.from(arguments))
return callBackendService(this.waveEnv, "block", "SaveTerminalState", Array.from(arguments))
}
SaveWaveAiData(arg2: string, arg3: WaveAIPromptMessageType[]): Promise<void> {
return WOS.callBackendService("block", "SaveWaveAiData", Array.from(arguments))
return callBackendService(this.waveEnv, "block", "SaveWaveAiData", Array.from(arguments))
}
}
export const BlockService = new BlockServiceType();
// clientservice.ClientService (client)
class ClientServiceType {
export class ClientServiceType {
waveEnv: WaveEnv;
constructor(waveEnv?: WaveEnv) {
this.waveEnv = waveEnv;
}
// @returns object updates
AgreeTos(): Promise<void> {
return WOS.callBackendService("client", "AgreeTos", Array.from(arguments))
return callBackendService(this.waveEnv, "client", "AgreeTos", Array.from(arguments))
}
FocusWindow(arg2: string): Promise<void> {
return WOS.callBackendService("client", "FocusWindow", Array.from(arguments))
return callBackendService(this.waveEnv, "client", "FocusWindow", Array.from(arguments))
}
GetAllConnStatus(): Promise<ConnStatus[]> {
return WOS.callBackendService("client", "GetAllConnStatus", Array.from(arguments))
return callBackendService(this.waveEnv, "client", "GetAllConnStatus", Array.from(arguments))
}
GetClientData(): Promise<Client> {
return WOS.callBackendService("client", "GetClientData", Array.from(arguments))
return callBackendService(this.waveEnv, "client", "GetClientData", Array.from(arguments))
}
GetTab(arg1: string): Promise<Tab> {
return WOS.callBackendService("client", "GetTab", Array.from(arguments))
return callBackendService(this.waveEnv, "client", "GetTab", Array.from(arguments))
}
TelemetryUpdate(arg2: boolean): Promise<void> {
return WOS.callBackendService("client", "TelemetryUpdate", Array.from(arguments))
return callBackendService(this.waveEnv, "client", "TelemetryUpdate", Array.from(arguments))
}
}
export const ClientService = new ClientServiceType();
// objectservice.ObjectService (object)
class ObjectServiceType {
export class ObjectServiceType {
waveEnv: WaveEnv;
constructor(waveEnv?: WaveEnv) {
this.waveEnv = waveEnv;
}
// @returns blockId (and object updates)
CreateBlock(blockDef: BlockDef, rtOpts: RuntimeOpts): Promise<string> {
return WOS.callBackendService("object", "CreateBlock", Array.from(arguments))
return callBackendService(this.waveEnv, "object", "CreateBlock", Array.from(arguments))
}
// @returns object updates
DeleteBlock(blockId: string): Promise<void> {
return WOS.callBackendService("object", "DeleteBlock", Array.from(arguments))
return callBackendService(this.waveEnv, "object", "DeleteBlock", Array.from(arguments))
}
// get wave object by oref
GetObject(oref: string): Promise<WaveObj> {
return WOS.callBackendService("object", "GetObject", Array.from(arguments))
return callBackendService(this.waveEnv, "object", "GetObject", Array.from(arguments))
}
// @returns objects
GetObjects(orefs: string[]): Promise<WaveObj[]> {
return WOS.callBackendService("object", "GetObjects", Array.from(arguments))
return callBackendService(this.waveEnv, "object", "GetObjects", Array.from(arguments))
}
// @returns object updates
UpdateObject(waveObj: WaveObj, returnUpdates: boolean): Promise<void> {
return WOS.callBackendService("object", "UpdateObject", Array.from(arguments))
return callBackendService(this.waveEnv, "object", "UpdateObject", Array.from(arguments))
}
// @returns object updates
UpdateObjectMeta(oref: string, meta: MetaType): Promise<void> {
return WOS.callBackendService("object", "UpdateObjectMeta", Array.from(arguments))
}
// @returns object updates
UpdateTabName(tabId: string, name: string): Promise<void> {
return WOS.callBackendService("object", "UpdateTabName", Array.from(arguments))
return callBackendService(this.waveEnv, "object", "UpdateObjectMeta", Array.from(arguments))
}
}
export const ObjectService = new ObjectServiceType();
// userinputservice.UserInputService (userinput)
class UserInputServiceType {
export class UserInputServiceType {
waveEnv: WaveEnv;
constructor(waveEnv?: WaveEnv) {
this.waveEnv = waveEnv;
}
SendUserInputResponse(arg1: UserInputResponse): Promise<void> {
return WOS.callBackendService("userinput", "SendUserInputResponse", Array.from(arguments))
return callBackendService(this.waveEnv, "userinput", "SendUserInputResponse", Array.from(arguments))
}
}
export const UserInputService = new UserInputServiceType();
// windowservice.WindowService (window)
class WindowServiceType {
export class WindowServiceType {
waveEnv: WaveEnv;
constructor(waveEnv?: WaveEnv) {
this.waveEnv = waveEnv;
}
CloseWindow(windowId: string, fromElectron: boolean): Promise<void> {
return WOS.callBackendService("window", "CloseWindow", Array.from(arguments))
return callBackendService(this.waveEnv, "window", "CloseWindow", Array.from(arguments))
}
CreateWindow(winSize: WinSize, workspaceId: string): Promise<WaveWindow> {
return WOS.callBackendService("window", "CreateWindow", Array.from(arguments))
return callBackendService(this.waveEnv, "window", "CreateWindow", Array.from(arguments))
}
GetWindow(windowId: string): Promise<WaveWindow> {
return WOS.callBackendService("window", "GetWindow", Array.from(arguments))
return callBackendService(this.waveEnv, "window", "GetWindow", Array.from(arguments))
}
// set window position and size
// @returns object updates
SetWindowPosAndSize(windowId: string, pos: Point, size: WinSize): Promise<void> {
return WOS.callBackendService("window", "SetWindowPosAndSize", Array.from(arguments))
return callBackendService(this.waveEnv, "window", "SetWindowPosAndSize", Array.from(arguments))
}
SwitchWorkspace(windowId: string, workspaceId: string): Promise<Workspace> {
return WOS.callBackendService("window", "SwitchWorkspace", Array.from(arguments))
return callBackendService(this.waveEnv, "window", "SwitchWorkspace", Array.from(arguments))
}
}
export const WindowService = new WindowServiceType();
// workspaceservice.WorkspaceService (workspace)
class WorkspaceServiceType {
export class WorkspaceServiceType {
waveEnv: WaveEnv;
constructor(waveEnv?: WaveEnv) {
this.waveEnv = waveEnv;
}
// @returns CloseTabRtn (and object updates)
CloseTab(workspaceId: string, tabId: string, fromElectron: boolean): Promise<CloseTabRtnType> {
return WOS.callBackendService("workspace", "CloseTab", Array.from(arguments))
return callBackendService(this.waveEnv, "workspace", "CloseTab", Array.from(arguments))
}
// @returns tabId (and object updates)
CreateTab(workspaceId: string, tabName: string, activateTab: boolean): Promise<string> {
return WOS.callBackendService("workspace", "CreateTab", Array.from(arguments))
return callBackendService(this.waveEnv, "workspace", "CreateTab", Array.from(arguments))
}
// @returns workspaceId
CreateWorkspace(name: string, icon: string, color: string, applyDefaults: boolean): Promise<string> {
return WOS.callBackendService("workspace", "CreateWorkspace", Array.from(arguments))
return callBackendService(this.waveEnv, "workspace", "CreateWorkspace", Array.from(arguments))
}
// @returns object updates
DeleteWorkspace(workspaceId: string): Promise<string> {
return WOS.callBackendService("workspace", "DeleteWorkspace", Array.from(arguments))
return callBackendService(this.waveEnv, "workspace", "DeleteWorkspace", Array.from(arguments))
}
// @returns colors
GetColors(): Promise<string[]> {
return WOS.callBackendService("workspace", "GetColors", Array.from(arguments))
return callBackendService(this.waveEnv, "workspace", "GetColors", Array.from(arguments))
}
// @returns icons
GetIcons(): Promise<string[]> {
return WOS.callBackendService("workspace", "GetIcons", Array.from(arguments))
return callBackendService(this.waveEnv, "workspace", "GetIcons", Array.from(arguments))
}
// @returns workspace
GetWorkspace(workspaceId: string): Promise<Workspace> {
return WOS.callBackendService("workspace", "GetWorkspace", Array.from(arguments))
return callBackendService(this.waveEnv, "workspace", "GetWorkspace", Array.from(arguments))
}
ListWorkspaces(): Promise<WorkspaceListEntry[]> {
return WOS.callBackendService("workspace", "ListWorkspaces", Array.from(arguments))
return callBackendService(this.waveEnv, "workspace", "ListWorkspaces", Array.from(arguments))
}
// @returns object updates
SetActiveTab(workspaceId: string, tabId: string): Promise<void> {
return WOS.callBackendService("workspace", "SetActiveTab", Array.from(arguments))
}
// @returns object updates
UpdateTabIds(workspaceId: string, tabIds: string[]): Promise<void> {
return WOS.callBackendService("workspace", "UpdateTabIds", Array.from(arguments))
return callBackendService(this.waveEnv, "workspace", "SetActiveTab", Array.from(arguments))
}
// @returns object updates
UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, applyDefaults: boolean): Promise<void> {
return WOS.callBackendService("workspace", "UpdateWorkspace", Array.from(arguments))
return callBackendService(this.waveEnv, "workspace", "UpdateWorkspace", Array.from(arguments))
}
}
export const WorkspaceService = new WorkspaceServiceType();
export const AllServiceTypes = {
"block": BlockServiceType,
"client": ClientServiceType,
"object": ObjectServiceType,
"userinput": UserInputServiceType,
"window": WindowServiceType,
"workspace": WorkspaceServiceType,
};
export const AllServiceImpls = {
"block": BlockService,
"client": ClientService,
"object": ObjectService,
"userinput": UserInputService,
"window": WindowService,
"workspace": WorkspaceService,
};

View file

@ -1,24 +1,28 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { useWaveEnv, WaveEnv } from "@/app/waveenv/waveenv";
import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
import { atom, Atom, PrimitiveAtom } from "jotai";
import { createContext, useContext } from "react";
import { globalStore } from "./jotaiStore";
import * as WOS from "./wos";
export type TabModelEnv = WaveEnvSubset<{
wos: WaveEnv["wos"];
}>;
const tabModelCache = new Map<string, TabModel>();
export const activeTabIdAtom = atom<string>(null) as PrimitiveAtom<string>;
export class TabModel {
tabId: string;
waveEnv: WaveEnv;
waveEnv: TabModelEnv;
tabAtom: Atom<Tab>;
tabNumBlocksAtom: Atom<number>;
isTermMultiInput = atom(false) as PrimitiveAtom<boolean>;
metaCache: Map<string, Atom<any>> = new Map();
constructor(tabId: string, waveEnv?: WaveEnv) {
constructor(tabId: string, waveEnv?: TabModelEnv) {
this.tabId = tabId;
this.waveEnv = waveEnv;
this.tabAtom = atom((get) => {
@ -46,16 +50,25 @@ export class TabModel {
}
}
export function getTabModelByTabId(tabId: string, waveEnv?: WaveEnv): TabModel {
let model = tabModelCache.get(tabId);
export function getTabModelByTabId(tabId: string, waveEnv?: TabModelEnv): TabModel {
if (!waveEnv?.isMock) {
let model = tabModelCache.get(tabId);
if (model == null) {
model = new TabModel(tabId, waveEnv);
tabModelCache.set(tabId, model);
}
return model;
}
const key = `TabModel:${tabId}`;
let model = waveEnv.mockModels.get(key);
if (model == null) {
model = new TabModel(tabId, waveEnv);
tabModelCache.set(tabId, model);
waveEnv.mockModels.set(key, model);
}
return model;
}
export function getActiveTabModel(waveEnv?: WaveEnv): TabModel | null {
export function getActiveTabModel(waveEnv?: TabModelEnv): TabModel | null {
const activeTabId = globalStore.get(activeTabIdAtom);
if (activeTabId == null) {
return null;
@ -66,11 +79,7 @@ export function getActiveTabModel(waveEnv?: WaveEnv): TabModel | null {
export const TabModelContext = createContext<TabModel | undefined>(undefined);
export function useTabModel(): TabModel {
const waveEnv = useWaveEnv();
const ctxModel = useContext(TabModelContext);
if (waveEnv?.mockTabModel != null) {
return waveEnv.mockTabModel;
}
if (ctxModel == null) {
throw new Error("useTabModel must be used within a TabModelProvider");
}
@ -78,10 +87,5 @@ export function useTabModel(): TabModel {
}
export function useTabModelMaybe(): TabModel {
const waveEnv = useWaveEnv();
const ctxModel = useContext(TabModelContext);
if (waveEnv?.mockTabModel != null) {
return waveEnv.mockTabModel;
}
return ctxModel;
return useContext(TabModelContext);
}

View file

@ -1,8 +1,9 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import type { WshClient } from "@/app/store/wshclient";
import { RpcApi } from "@/app/store/wshclientapi";
import { isPreviewWindow } from "@/app/store/windowtype";
import { isBlank } from "@/util/util";
import { Subject } from "rxjs";
@ -43,6 +44,9 @@ function wpsReconnectHandler() {
}
function updateWaveEventSub(eventType: string) {
if (isPreviewWindow()) {
return;
}
const subjects = waveEventSubjects.get(eventType);
if (subjects == null) {
RpcApi.EventUnsubCommand(WpsRpcClient, eventType, { noresponse: true });
@ -84,7 +88,7 @@ function waveEventSubscribeSingle<T extends WaveEventName>(subscription: WaveEve
function waveEventUnsubscribe(...unsubscribes: WaveEventUnsubscribe[]) {
const eventTypeSet = new Set<string>();
for (const unsubscribe of unsubscribes) {
let subjects = waveEventSubjects.get(unsubscribe.eventType);
const subjects = waveEventSubjects.get(unsubscribe.eventType);
if (subjects == null) {
return;
}

View file

@ -930,6 +930,18 @@ export class RpcApiType {
return client.wshRpcCall("testmultiarg", { args: [arg1, arg2, arg3] }, opts);
}
// command "updatetabname" [call]
UpdateTabNameCommand(client: WshClient, arg1: string, arg2: string, opts?: RpcOpts): Promise<void> {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "updatetabname", { args: [arg1, arg2] }, opts);
return client.wshRpcCall("updatetabname", { args: [arg1, arg2] }, opts);
}
// command "updateworkspacetabids" [call]
UpdateWorkspaceTabIdsCommand(client: WshClient, arg1: string, arg2: string[], opts?: RpcOpts): Promise<void> {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "updateworkspacetabids", { args: [arg1, arg2] }, opts);
return client.wshRpcCall("updateworkspacetabids", { args: [arg1, arg2] }, opts);
}
// command "vdomasyncinitiation" [call]
VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise<void> {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "vdomasyncinitiation", data, opts);

View file

@ -2,21 +2,32 @@
// SPDX-License-Identifier: Apache-2.0
import { getTabBadgeAtom, sortBadgesForTab } from "@/app/store/badge";
import { atoms, getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global";
import { RpcApi } from "@/app/store/wshclientapi";
import { getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv";
import { Button } from "@/element/button";
import { ContextMenuModel } from "@/store/contextmenu";
import { validateCssColor } from "@/util/color-validator";
import { fireAndForget, makeIconClass } from "@/util/util";
import clsx from "clsx";
import { useAtomValue } from "jotai";
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { v7 as uuidv7 } from "uuid";
import { ObjectService } from "../store/services";
import { makeORef, useWaveObjectValue } from "../store/wos";
import { makeORef } from "../store/wos";
import "./tab.scss";
type TabEnv = WaveEnvSubset<{
rpc: {
ActivityCommand: WaveEnv["rpc"]["ActivityCommand"];
SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"];
UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"];
};
atoms: {
fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"];
};
wos: WaveEnv["wos"];
showContextMenu: WaveEnv["showContextMenu"];
}>;
interface TabVProps {
tabId: string;
tabName: string;
@ -95,7 +106,10 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
onRename,
renameRef,
} = props;
const [originalName, setOriginalName] = useState(tabName);
const MaxTabNameLength = 14;
const truncateTabName = (name: string) => [...(name ?? "")].slice(0, MaxTabNameLength).join("");
const displayName = truncateTabName(tabName);
const [originalName, setOriginalName] = useState(displayName);
const [isEditable, setIsEditable] = useState(false);
const editableRef = useRef<HTMLDivElement>(null);
@ -105,7 +119,7 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
useImperativeHandle(ref, () => tabRef.current as HTMLDivElement);
useEffect(() => {
setOriginalName(tabName);
setOriginalName(truncateTabName(tabName));
}, [tabName]);
useEffect(() => {
@ -181,8 +195,11 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
event.preventDefault();
event.stopPropagation();
} else if (curLen >= 14 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
const selection = window.getSelection();
if (!selection || selection.isCollapsed) {
event.preventDefault();
event.stopPropagation();
}
}
};
@ -222,7 +239,7 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
onKeyDown={handleKeyDown}
suppressContentEditableWarning={true}
>
{tabName}
{displayName}
</div>
<TabBadges badges={badges} flagColor={flagColor} />
<Button
@ -253,7 +270,8 @@ const FlagColors: { label: string; value: string }[] = [
function buildTabContextMenu(
id: string,
renameRef: React.RefObject<(() => void) | null>,
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void,
env: TabEnv
): ContextMenuItem[] {
const menu: ContextMenuItem[] = [];
menu.push(
@ -271,17 +289,23 @@ function buildTabContextMenu(
label: "None",
type: "checkbox",
checked: currentFlagColor == null,
click: () => fireAndForget(() => ObjectService.UpdateObjectMeta(tabORef, { "tab:flagcolor": null })),
click: () =>
fireAndForget(() =>
env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": null } })
),
},
...FlagColors.map((fc) => ({
label: fc.label,
type: "checkbox" as const,
checked: currentFlagColor === fc.value,
click: () => fireAndForget(() => ObjectService.UpdateObjectMeta(tabORef, { "tab:flagcolor": fc.value })),
click: () =>
fireAndForget(() =>
env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": fc.value } })
),
})),
];
menu.push({ label: "Flag Tab", type: "submenu", submenu: flagSubmenu }, { type: "separator" });
const fullConfig = globalStore.get(atoms.fullConfigAtom);
const fullConfig = globalStore.get(env.atoms.fullConfigAtom);
const bgPresets: string[] = [];
for (const key in fullConfig?.presets ?? {}) {
if (key.startsWith("bg@") && fullConfig.presets[key] != null) {
@ -303,8 +327,8 @@ function buildTabContextMenu(
label: preset["display:name"] ?? presetName,
click: () =>
fireAndForget(async () => {
await ObjectService.UpdateObjectMeta(oref, preset);
RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });
await env.rpc.SetMetaCommand(TabRpcClient, { oref, meta: preset });
env.rpc.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });
recordTEvent("action:settabtheme");
}),
});
@ -330,8 +354,9 @@ interface TabProps {
const TabInner = forwardRef<HTMLDivElement, TabProps>((props, ref) => {
const { id, active, showDivider, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart } = props;
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
const badges = useAtomValue(getTabBadgeAtom(id));
const env = useWaveEnv<TabEnv>();
const [tabData, _] = env.wos.useWaveObjectValue<Tab>(makeORef("tab", id));
const badges = useAtomValue(getTabBadgeAtom(id, env));
const rawFlagColor = tabData?.meta?.["tab:flagcolor"];
let flagColor: string | null = null;
@ -361,18 +386,18 @@ const TabInner = forwardRef<HTMLDivElement, TabProps>((props, ref) => {
const handleContextMenu = useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.preventDefault();
const menu = buildTabContextMenu(id, renameRef, onClose);
ContextMenuModel.getInstance().showContextMenu(menu, e);
const menu = buildTabContextMenu(id, renameRef, onClose, env);
env.showContextMenu(menu, e);
},
[id, onClose]
[id, onClose, env]
);
const handleRename = useCallback(
(newName: string) => {
fireAndForget(() => ObjectService.UpdateTabName(id, newName));
fireAndForget(() => env.rpc.UpdateTabNameCommand(TabRpcClient, id, newName));
setTimeout(() => refocusNode(null), 10);
},
[id]
[id, env]
);
return (

View file

@ -40,15 +40,6 @@
border: 1px solid var(--border-color);
}
.tab-bar-right {
display: flex;
flex-direction: row;
gap: 6px;
height: 100%;
align-items: center;
margin-left: auto;
}
.add-tab {
padding: 0 10px;
height: 27px;

View file

@ -1,22 +1,20 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Button } from "@/app/element/button";
import { Tooltip } from "@/app/element/tooltip";
import { modalsModel } from "@/app/store/modalmodel";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { useWaveEnv } from "@/app/waveenv/waveenv";
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
import { deleteLayoutModelForTab } from "@/layout/index";
import { atoms, createTab, getApi, getSettingsKeyAtom, globalStore, setActiveTab } from "@/store/global";
import { isMacOS, isWindows } from "@/util/platformutil";
import { fireAndForget } from "@/util/util";
import { useAtomValue } from "jotai";
import { OverlayScrollbars } from "overlayscrollbars";
import { createRef, memo, useCallback, useEffect, useRef, useState } from "react";
import { debounce } from "throttle-debounce";
import { IconButton } from "../element/iconbutton";
import { WorkspaceService } from "../store/services";
import { Tab } from "./tab";
import "./tabbar.scss";
import { TabBarEnv } from "./tabbarenv";
import { UpdateStatusBanner } from "./updatebanner";
import { WorkspaceSwitcher } from "./workspaceswitcher";
@ -44,8 +42,9 @@ interface TabBarProps {
}
const WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject<HTMLDivElement> }) => {
const env = useWaveEnv<TabBarEnv>();
const aiPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom);
const hideAiButton = useAtomValue(getSettingsKeyAtom("app:hideaibutton"));
const hideAiButton = useAtomValue(env.getSettingsKeyAtom("app:hideaibutton"));
const onClick = () => {
const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible();
@ -73,7 +72,8 @@ const WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject<HTMLDivElement
WaveAIButton.displayName = "WaveAIButton";
const ConfigErrorMessage = () => {
const fullConfig = useAtomValue(atoms.fullConfigAtom);
const env = useWaveEnv<TabBarEnv>();
const fullConfig = useAtomValue(env.atoms.fullConfigAtom);
if (fullConfig?.configerrors == null || fullConfig?.configerrors.length == 0) {
return (
@ -108,25 +108,28 @@ const ConfigErrorMessage = () => {
);
};
const ConfigErrorIcon = ({ buttonRef }: { buttonRef: React.RefObject<HTMLElement> }) => {
const fullConfig = useAtomValue(atoms.fullConfigAtom);
const ConfigErrorIcon = () => {
const env = useWaveEnv<TabBarEnv>();
const hasConfigErrors = useAtomValue(env.atoms.hasConfigErrors);
function handleClick() {
const handleClick = useCallback(() => {
modalsModel.pushModal("MessageModal", { children: <ConfigErrorMessage /> });
}
}, []);
if (fullConfig?.configerrors == null || fullConfig?.configerrors.length == 0) {
if (!hasConfigErrors) {
return null;
}
return (
<Button
ref={buttonRef as React.RefObject<HTMLButtonElement>}
className="text-black flex-[0_0_fit-content] !h-full !px-3 red"
onClick={handleClick}
<Tooltip
content="Configuration Error"
placement="bottom"
hideOnClick
divClassName="flex h-[22px] px-2 mb-1 items-center rounded-md box-border cursor-pointer hover:bg-hoverbg transition-colors text-[12px] text-error"
divStyle={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
divOnClick={handleClick}
>
<i className="fa fa-solid fa-exclamation-triangle" />
Config Error
</Button>
<i className="fa fa-solid fa-triangle-exclamation" />
</Tooltip>
);
};
@ -150,6 +153,7 @@ function strArrayIsEqual(a: string[], b: string[]) {
}
const TabBar = memo(({ workspace }: TabBarProps) => {
const env = useWaveEnv<TabBarEnv>();
const [tabIds, setTabIds] = useState<string[]>([]);
const [dragStartPositions, setDragStartPositions] = useState<number[]>([]);
const [draggingTab, setDraggingTab] = useState<string>();
@ -174,19 +178,21 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
});
const osInstanceRef = useRef<OverlayScrollbars>(null);
const draggerLeftRef = useRef<HTMLDivElement>(null);
const draggerRightRef = useRef<HTMLDivElement>(null);
const rightContainerRef = useRef<HTMLDivElement>(null);
const workspaceSwitcherRef = useRef<HTMLDivElement>(null);
const waveAIButtonRef = useRef<HTMLDivElement>(null);
const appMenuButtonRef = useRef<HTMLDivElement>(null);
const tabWidthRef = useRef<number>(TabDefaultWidth);
const scrollableRef = useRef<boolean>(false);
const updateStatusBannerRef = useRef<HTMLButtonElement>(null);
const configErrorButtonRef = useRef<HTMLElement>(null);
const prevAllLoadedRef = useRef<boolean>(false);
const activeTabId = useAtomValue(atoms.staticTabId);
const isFullScreen = useAtomValue(atoms.isFullScreen);
const zoomFactor = useAtomValue(atoms.zoomFactorAtom);
const settings = useAtomValue(atoms.settingsAtom);
const activeTabId = useAtomValue(env.atoms.staticTabId);
const isFullScreen = useAtomValue(env.atoms.isFullScreen);
const zoomFactor = useAtomValue(env.atoms.zoomFactorAtom);
const showMenuBar = useAtomValue(env.getSettingsKeyAtom("window:showmenubar"));
const confirmClose = useAtomValue(env.getSettingsKeyAtom("tab:confirmclose")) ?? false;
const hideAiButton = useAtomValue(env.getSettingsKeyAtom("app:hideaibutton"));
const appUpdateStatus = useAtomValue(env.atoms.updaterStatusAtom);
const hasConfigErrors = useAtomValue(env.atoms.hasConfigErrors);
let prevDelta: number;
let prevDragDirection: string;
@ -230,22 +236,24 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
const tabBar = tabBarRef.current;
if (tabBar === null) return;
const getOuterWidth = (el: HTMLElement): number => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return rect.width + parseFloat(style.marginLeft) + parseFloat(style.marginRight);
};
const tabbarWrapperWidth = tabbarWrapperRef.current.getBoundingClientRect().width;
const windowDragLeftWidth = draggerLeftRef.current.getBoundingClientRect().width;
const windowDragRightWidth = draggerRightRef.current?.getBoundingClientRect().width ?? 0;
const addBtnWidth = addBtnRef.current.getBoundingClientRect().width;
const updateStatusLabelWidth = updateStatusBannerRef.current?.getBoundingClientRect().width ?? 0;
const configErrorWidth = configErrorButtonRef.current?.getBoundingClientRect().width ?? 0;
const rightContainerWidth = rightContainerRef.current?.getBoundingClientRect().width ?? 0;
const addBtnWidth = getOuterWidth(addBtnRef.current);
const appMenuButtonWidth = appMenuButtonRef.current?.getBoundingClientRect().width ?? 0;
const workspaceSwitcherWidth = workspaceSwitcherRef.current?.getBoundingClientRect().width ?? 0;
const waveAIButtonWidth = waveAIButtonRef.current?.getBoundingClientRect().width ?? 0;
const waveAIButtonWidth = waveAIButtonRef.current != null ? getOuterWidth(waveAIButtonRef.current) : 0;
const nonTabElementsWidth =
windowDragLeftWidth +
windowDragRightWidth +
rightContainerWidth +
addBtnWidth +
updateStatusLabelWidth +
configErrorWidth +
appMenuButtonWidth +
workspaceSwitcherWidth +
waveAIButtonWidth;
@ -306,20 +314,23 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
saveTabsPositionDebounced();
}, [tabIds, newTabId, isFullScreen]);
const reinitVersion = useAtomValue(atoms.reinitVersion);
// update layout on reinit version
const reinitVersion = useAtomValue(env.atoms.reinitVersion);
useEffect(() => {
if (reinitVersion > 0) {
setSizeAndPosition();
}
}, [reinitVersion]);
// update layout on resize
useEffect(() => {
window.addEventListener("resize", () => handleResizeTabs());
window.addEventListener("resize", handleResizeTabs);
return () => {
window.removeEventListener("resize", () => handleResizeTabs());
window.removeEventListener("resize", handleResizeTabs);
};
}, [handleResizeTabs]);
// update layout on changed tabIds, tabsLoaded, newTabId, hideAiButton, appUpdateStatus, hasConfigErrors, or zoomFactor
useEffect(() => {
// Check if all tabs are loaded
const allLoaded = tabIds.length > 0 && tabIds.every((id) => tabsLoaded[id]);
@ -330,7 +341,17 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
prevAllLoadedRef.current = true;
}
}
}, [tabIds, tabsLoaded, newTabId, saveTabsPosition]);
}, [
tabIds,
tabsLoaded,
newTabId,
saveTabsPosition,
hideAiButton,
appUpdateStatus,
hasConfigErrors,
zoomFactor,
showMenuBar,
]);
const getDragDirection = (currentX: number) => {
let dragDirection: string;
@ -483,7 +504,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
// Reset dragging state
setDraggingTab(null);
// Update workspace tab ids
fireAndForget(() => WorkspaceService.UpdateTabIds(workspace.oid, tabIds));
fireAndForget(() => env.rpc.UpdateWorkspaceTabIdsCommand(TabRpcClient, workspace.oid, tabIds));
}),
[]
);
@ -547,7 +568,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
const handleSelectTab = (tabId: string) => {
if (!draggingTabDataRef.current.dragged) {
setActiveTab(tabId);
env.electron.setActiveTab(tabId);
}
};
@ -569,7 +590,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
);
const handleAddTab = () => {
createTab();
env.electron.createTab();
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease");
updateScrollDebounced();
@ -579,10 +600,8 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
const handleCloseTab = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, tabId: string) => {
event?.stopPropagation();
const ws = globalStore.get(atoms.workspace);
const confirmClose = globalStore.get(getSettingsKeyAtom("tab:confirmclose")) ?? false;
getApi()
.closeTab(ws.oid, tabId, confirmClose)
env.electron
.closeTab(workspace.oid, tabId, confirmClose)
.then((didClose) => {
if (didClose) {
tabsWrapperRef.current?.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease");
@ -607,15 +626,15 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
const activeTabIndex = tabIds.indexOf(activeTabId);
function onEllipsisClick() {
getApi().showWorkspaceAppMenu(workspace.oid);
env.electron.showWorkspaceAppMenu(workspace.oid);
}
const tabsWrapperWidth = tabIds.length * tabWidthRef.current;
const showAppMenuButton = isWindows() || (!isMacOS() && !settings["window:showmenubar"]);
const showAppMenuButton = env.isWindows() || (!env.isMacOS() && !showMenuBar);
// Calculate window drag left width based on platform and state
let windowDragLeftWidth = 10;
if (isMacOS() && !isFullScreen) {
if (env.isMacOS() && !isFullScreen) {
if (zoomFactor > 0) {
windowDragLeftWidth = 74 / zoomFactor;
} else {
@ -625,7 +644,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
// Calculate window drag right width
let windowDragRightWidth = 12;
if (isWindows()) {
if (env.isWindows()) {
if (zoomFactor > 0) {
windowDragRightWidth = 139 / zoomFactor;
} else {
@ -633,12 +652,6 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
}
}
const addtabButtonDecl: IconButtonDecl = {
elemtype: "iconbutton",
icon: "plus",
click: handleAddTab,
title: "Add Tab",
};
return (
<div ref={tabbarWrapperRef} className="tab-bar-wrapper">
<div
@ -690,12 +703,20 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
})}
</div>
</div>
<IconButton className="add-tab" ref={addBtnRef} decl={addtabButtonDecl} />
<div className="tab-bar-right">
<UpdateStatusBanner ref={updateStatusBannerRef} />
<ConfigErrorIcon buttonRef={configErrorButtonRef} />
<button
ref={addBtnRef}
title="Add Tab"
className="flex h-[22px] px-2 mb-1 mx-1 items-center rounded-md box-border cursor-pointer hover:bg-hoverbg transition-colors text-[12px] text-secondary hover:text-primary"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
onClick={handleAddTab}
>
<i className="fa fa-solid fa-plus" />
</button>
<div className="flex-1" />
<div ref={rightContainerRef} className="flex flex-row gap-1 items-end">
<UpdateStatusBanner />
<ConfigErrorIcon />
<div
ref={draggerRightRef}
className="h-full shrink-0 z-window-drag"
style={{ width: windowDragRightWidth, WebkitAppRegion: "drag" } as any}
/>
@ -704,4 +725,4 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
);
});
export { TabBar };
export { ConfigErrorIcon, ConfigErrorMessage, TabBar, WaveAIButton };

View file

@ -0,0 +1,31 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
export type TabBarEnv = WaveEnvSubset<{
electron: {
createTab: WaveEnv["electron"]["createTab"];
closeTab: WaveEnv["electron"]["closeTab"];
setActiveTab: WaveEnv["electron"]["setActiveTab"];
showWorkspaceAppMenu: WaveEnv["electron"]["showWorkspaceAppMenu"];
installAppUpdate: WaveEnv["electron"]["installAppUpdate"];
};
rpc: {
UpdateWorkspaceTabIdsCommand: WaveEnv["rpc"]["UpdateWorkspaceTabIdsCommand"];
};
atoms: {
fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"];
hasConfigErrors: WaveEnv["atoms"]["hasConfigErrors"];
staticTabId: WaveEnv["atoms"]["staticTabId"];
isFullScreen: WaveEnv["atoms"]["isFullScreen"];
zoomFactorAtom: WaveEnv["atoms"]["zoomFactorAtom"];
reinitVersion: WaveEnv["atoms"]["reinitVersion"];
updaterStatusAtom: WaveEnv["atoms"]["updaterStatusAtom"];
};
wos: WaveEnv["wos"];
getSettingsKeyAtom: SettingsKeyAtomFnType<"app:hideaibutton" | "tab:confirmclose" | "window:showmenubar">;
mockSetWaveObj: WaveEnv["mockSetWaveObj"];
isWindows: WaveEnv["isWindows"];
isMacOS: WaveEnv["isMacOS"];
}>;

View file

@ -1,69 +1,54 @@
import { Button } from "@/element/button";
import { atoms, getApi } from "@/store/global";
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Tooltip } from "@/element/tooltip";
import { useWaveEnv } from "@/app/waveenv/waveenv";
import { TabBarEnv } from "./tabbarenv";
import { useAtomValue } from "jotai";
import { forwardRef, memo, useEffect, useState } from "react";
import { memo, useCallback } from "react";
const UpdateStatusBannerComponent = forwardRef<HTMLButtonElement>((_, ref) => {
let appUpdateStatus = useAtomValue(atoms.updaterStatusAtom);
let [updateStatusMessage, setUpdateStatusMessage] = useState<string>();
const [dismissBannerTimeout, setDismissBannerTimeout] = useState<NodeJS.Timeout>();
useEffect(() => {
let message: string;
let dismissBanner = false;
switch (appUpdateStatus) {
case "ready":
message = "Update Available";
break;
case "downloading":
message = "Downloading Update";
break;
case "installing":
message = "Installing Update";
break;
case "error":
message = "Updater Error: Try Checking Again";
dismissBanner = true;
break;
default:
break;
}
setUpdateStatusMessage(message);
// Clear any existing timeout
if (dismissBannerTimeout) {
clearTimeout(dismissBannerTimeout);
}
// If we want to dismiss the banner, set the new timeout, otherwise clear the state
if (dismissBanner) {
setDismissBannerTimeout(
setTimeout(() => {
setUpdateStatusMessage(null);
setDismissBannerTimeout(null);
}, 10000)
);
} else {
setDismissBannerTimeout(null);
}
}, [appUpdateStatus]);
function onClick() {
getApi().installAppUpdate();
function getUpdateStatusMessage(status: string): string {
switch (status) {
case "ready":
return "Update";
case "downloading":
return "Downloading";
case "installing":
return "Installing";
default:
return null;
}
if (updateStatusMessage) {
return (
<Button
className="text-black bg-[var(--accent-color)] flex-[0_0_fit-content] !h-full !px-3 disabled:!opacity-[unset]"
title={appUpdateStatus === "ready" ? "Click to Install Update" : updateStatusMessage}
onClick={onClick}
disabled={appUpdateStatus !== "ready"}
ref={ref}
>
{updateStatusMessage}
</Button>
);
}
});
}
export const UpdateStatusBanner = memo(UpdateStatusBannerComponent) as typeof UpdateStatusBannerComponent;
const UpdateStatusBannerComponent = () => {
const env = useWaveEnv<TabBarEnv>();
const appUpdateStatus = useAtomValue(env.atoms.updaterStatusAtom);
const updateStatusMessage = getUpdateStatusMessage(appUpdateStatus);
const onClick = useCallback(() => {
env.electron.installAppUpdate();
}, [env]);
if (!updateStatusMessage) {
return null;
}
const isReady = appUpdateStatus === "ready";
const tooltipContent = isReady ? "Click to Install Update" : updateStatusMessage;
return (
<Tooltip
content={tooltipContent}
placement="bottom"
divOnClick={isReady ? onClick : undefined}
divClassName={`flex items-center gap-1 px-2 mb-1 h-[22px] text-xs font-medium text-black bg-accent rounded-sm transition-all ${isReady ? "cursor-pointer hover:bg-[var(--button-green-border-color)]" : ""}`}
divStyle={{ WebkitAppRegion: "no-drag" } as any}
>
<i className="fa fa-download" />
{updateStatusMessage}
</Tooltip>
);
};
UpdateStatusBannerComponent.displayName = "UpdateStatusBannerComponent";
export const UpdateStatusBanner = memo(UpdateStatusBannerComponent);

View file

@ -1,6 +1,7 @@
// Copyright 2025, Command Line
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { useWaveEnv, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
import {
ExpandableMenu,
ExpandableMenuItem,
@ -18,13 +19,27 @@ import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { CSSProperties, forwardRef, useCallback, useEffect } from "react";
import WorkspaceSVG from "../asset/workspace.svg";
import { IconButton } from "../element/iconbutton";
import { atoms, getApi } from "../store/global";
import { WorkspaceService } from "../store/services";
import { getObjectValue, makeORef } from "../store/wos";
import { globalStore } from "@/app/store/jotaiStore";
import { makeORef } from "../store/wos";
import { waveEventSubscribeSingle } from "../store/wps";
import { WorkspaceEditor } from "./workspaceeditor";
import "./workspaceswitcher.scss";
export type WorkspaceSwitcherEnv = WaveEnvSubset<{
electron: {
deleteWorkspace: WaveEnv["electron"]["deleteWorkspace"];
createWorkspace: WaveEnv["electron"]["createWorkspace"];
switchWorkspace: WaveEnv["electron"]["switchWorkspace"];
};
atoms: {
workspace: WaveEnv["atoms"]["workspace"];
};
services: {
workspace: WaveEnv["services"]["workspace"];
};
wos: WaveEnv["wos"];
}>;
type WorkspaceListEntry = {
windowId: string;
workspace: Workspace;
@ -35,23 +50,24 @@ const workspaceMapAtom = atom<WorkspaceList>([]);
const workspaceSplitAtom = splitAtom(workspaceMapAtom);
const editingWorkspaceAtom = atom<string>();
const WorkspaceSwitcher = forwardRef<HTMLDivElement>((_, ref) => {
const env = useWaveEnv<WorkspaceSwitcherEnv>();
const setWorkspaceList = useSetAtom(workspaceMapAtom);
const activeWorkspace = useAtomValueSafe(atoms.workspace);
const activeWorkspace = useAtomValueSafe(env.atoms.workspace);
const workspaceList = useAtomValue(workspaceSplitAtom);
const setEditingWorkspace = useSetAtom(editingWorkspaceAtom);
const updateWorkspaceList = useCallback(async () => {
const workspaceList = await WorkspaceService.ListWorkspaces();
const workspaceList = await env.services.workspace.ListWorkspaces();
if (!workspaceList) {
return;
}
const newList: WorkspaceList = [];
for (const entry of workspaceList) {
// This just ensures that the atom exists for easier setting of the object
getObjectValue(makeORef("workspace", entry.workspaceid));
globalStore.get(env.wos.getWaveObjectAtom(makeORef("workspace", entry.workspaceid)));
newList.push({
windowId: entry.windowid,
workspace: await WorkspaceService.GetWorkspace(entry.workspaceid),
workspace: await env.services.workspace.GetWorkspace(entry.workspaceid),
});
}
setWorkspaceList(newList);
@ -71,7 +87,7 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement>((_, ref) => {
}, []);
const onDeleteWorkspace = useCallback((workspaceId: string) => {
getApi().deleteWorkspace(workspaceId);
env.electron.deleteWorkspace(workspaceId);
}, []);
const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon);
@ -84,7 +100,7 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement>((_, ref) => {
const saveWorkspace = () => {
fireAndForget(async () => {
await WorkspaceService.UpdateWorkspace(activeWorkspace.oid, "", "", "", true);
await env.services.workspace.UpdateWorkspace(activeWorkspace.oid, "", "", "", true);
await updateWorkspaceList();
setEditingWorkspace(activeWorkspace.oid);
});
@ -118,7 +134,7 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement>((_, ref) => {
<div className="actions">
{isActiveWorkspaceSaved ? (
<ExpandableMenuItem onClick={() => getApi().createWorkspace()}>
<ExpandableMenuItem onClick={() => env.electron.createWorkspace()}>
<ExpandableMenuItemLeftElement>
<i className="fa-sharp fa-solid fa-plus"></i>
</ExpandableMenuItemLeftElement>
@ -145,7 +161,8 @@ const WorkspaceSwitcherItem = ({
entryAtom: PrimitiveAtom<WorkspaceListEntry>;
onDeleteWorkspace: (workspaceId: string) => void;
}) => {
const activeWorkspace = useAtomValueSafe(atoms.workspace);
const env = useWaveEnv<WorkspaceSwitcherEnv>();
const activeWorkspace = useAtomValueSafe(env.atoms.workspace);
const [workspaceEntry, setWorkspaceEntry] = useAtom(entryAtom);
const [editingWorkspace, setEditingWorkspace] = useAtom(editingWorkspaceAtom);
@ -156,7 +173,7 @@ const WorkspaceSwitcherItem = ({
setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace });
if (newWorkspace.name != "") {
fireAndForget(() =>
WorkspaceService.UpdateWorkspace(
env.services.workspace.UpdateWorkspace(
workspace.oid,
newWorkspace.name,
newWorkspace.icon,
@ -200,7 +217,7 @@ const WorkspaceSwitcherItem = ({
>
<ExpandableMenuItemGroupTitle
onClick={() => {
getApi().switchWorkspace(workspace.oid);
env.electron.switchWorkspace(workspace.oid);
// Create a fake escape key event to close the popover
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
}}

View file

@ -14,10 +14,10 @@ import * as React from "react";
import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions";
import { waveEventSubscribeSingle } from "@/app/store/wps";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import type { BlockMetaKeyAtomFnType, WaveEnv } from "@/app/waveenv/waveenv";
import type { BlockMetaKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
export type SysinfoEnv = {
export type SysinfoEnv = WaveEnvSubset<{
rpc: {
EventReadHistoryCommand: WaveEnv["rpc"]["EventReadHistoryCommand"];
SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"];
@ -27,7 +27,7 @@ export type SysinfoEnv = {
};
getConnStatusAtom: WaveEnv["getConnStatusAtom"];
getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"graph:numpoints" | "sysinfo:type" | "connection" | "count">;
};
}>;
const DefaultNumPoints = 120;

View file

@ -12,7 +12,6 @@ import {
recordTEvent,
WOS,
} from "@/store/global";
import * as services from "@/store/services";
import { base64ToString, fireAndForget, isSshConnName, isWslConnName } from "@/util/util";
import debug from "debug";
import type { TermWrap } from "./termwrap";
@ -243,8 +242,9 @@ export function handleOsc7Command(data: string, blockId: string, loaded: boolean
setTimeout(() => {
fireAndForget(async () => {
await services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", blockId), {
"cmd:cwd": pathPart,
await RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", blockId),
meta: { "cmd:cwd": pathPart },
});
const rtInfo = { "shell:hascurcwd": true };

View file

@ -1,7 +1,7 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import type { TabModel } from "@/app/store/tab-model";
import type { AllServiceImpls } from "@/app/store/services";
import { RpcApiType } from "@/app/store/wshclientapi";
import { Atom, PrimitiveAtom } from "jotai";
import React from "react";
@ -33,18 +33,27 @@ type ComplexWaveEnvKeys = {
electron: WaveEnv["electron"];
atoms: WaveEnv["atoms"];
wos: WaveEnv["wos"];
services: WaveEnv["services"];
};
export type WaveEnvSubset<T> = OmitNever<{
[K in keyof T]: K extends keyof ComplexWaveEnvKeys
? Subset<T[K], ComplexWaveEnvKeys[K]>
: K extends keyof WaveEnv
? T[K]
: never;
}>;
type WaveEnvMockFields = {
isMock: WaveEnv["isMock"];
mockSetWaveObj: WaveEnv["mockSetWaveObj"];
mockModels: WaveEnv["mockModels"];
};
export type WaveEnvSubset<T> = WaveEnvMockFields &
OmitNever<{
[K in keyof T]: K extends keyof ComplexWaveEnvKeys
? Subset<T[K], ComplexWaveEnvKeys[K]>
: K extends keyof WaveEnv
? T[K]
: never;
}>;
// default implementation for production is in ./waveenvimpl.ts
export type WaveEnv = {
isMock: boolean;
electron: ElectronApi;
rpc: RpcApiType;
platform: NodeJS.Platform;
@ -53,6 +62,8 @@ export type WaveEnv = {
isMacOS: () => boolean;
atoms: GlobalAtomsType;
createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => Promise<string>;
services: typeof AllServiceImpls;
callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => Promise<any>;
showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => void;
getConnStatusAtom: (conn: string) => PrimitiveAtom<ConnStatus>;
getLocalHostDisplayNameAtom: () => Atom<string>;
@ -65,7 +76,10 @@ export type WaveEnv = {
getSettingsKeyAtom: SettingsKeyAtomFnType;
getBlockMetaKeyAtom: BlockMetaKeyAtomFnType;
getConnConfigKeyAtom: ConnConfigKeyAtomFnType;
mockTabModel?: TabModel;
// the mock fields are only usable in the preview server (may be be null or throw errors in production)
mockSetWaveObj: <T extends WaveObj>(oref: string, obj: T) => void;
mockModels: Map<any, any>;
};
export const WaveEnvContext = React.createContext<WaveEnv>(null);

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import { ContextMenuModel } from "@/app/store/contextmenu";
import { AllServiceImpls } from "@/app/store/services";
import {
atoms,
createBlock,
@ -19,6 +20,7 @@ import { isMacOS, isWindows, PLATFORM } from "@/util/platformutil";
export function makeWaveEnvImpl(): WaveEnv {
return {
isMock: false,
electron: (window as any).api,
rpc: RpcApi,
getSettingsKeyAtom,
@ -28,6 +30,8 @@ export function makeWaveEnvImpl(): WaveEnv {
isMacOS,
atoms,
createBlock,
services: AllServiceImpls,
callBackendService: WOS.callBackendService,
showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => {
ContextMenuModel.getInstance().showContextMenu(menu, e);
},
@ -41,5 +45,10 @@ export function makeWaveEnvImpl(): WaveEnv {
},
getBlockMetaKeyAtom,
getConnConfigKeyAtom,
mockSetWaveObj: <T extends WaveObj>(_oref: string, _obj: T) => {
throw new Error("mockSetWaveObj is only available in the preview server");
},
mockModels: new Map<any, any>(),
};
}

View file

@ -30,7 +30,7 @@ export type WidgetsEnv = WaveEnvSubset<{
};
atoms: {
fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"];
workspace: WaveEnv["atoms"]["workspace"];
workspaceId: WaveEnv["atoms"]["workspaceId"];
hasCustomAIPresetsAtom: WaveEnv["atoms"]["hasCustomAIPresetsAtom"];
};
createBlock: WaveEnv["createBlock"];
@ -348,7 +348,7 @@ SettingsFloatingWindow.displayName = "SettingsFloatingWindow";
const Widgets = memo(() => {
const env = useWaveEnv<WidgetsEnv>();
const fullConfig = useAtomValue(env.atoms.fullConfigAtom);
const workspace = useAtomValue(env.atoms.workspace);
const workspaceId = useAtomValue(env.atoms.workspaceId);
const hasCustomAIPresets = useAtomValue(env.atoms.hasCustomAIPresetsAtom);
const [mode, setMode] = useState<"normal" | "compact" | "supercompact">("normal");
const containerRef = useRef<HTMLDivElement>(null);
@ -361,7 +361,7 @@ const Widgets = memo(() => {
if (!hasCustomAIPresets && key === "defwidget@ai") {
return false;
}
return shouldIncludeWidgetForWorkspace(widget, workspace?.oid);
return shouldIncludeWidgetForWorkspace(widget, workspaceId);
})
);
const widgets = sortByDisplayOrder(filteredWidgets);

View file

@ -2,16 +2,42 @@
// SPDX-License-Identifier: Apache-2.0
import { makeDefaultConnStatus } from "@/app/store/global";
import { TabModel } from "@/app/store/tab-model";
import { globalStore } from "@/app/store/jotaiStore";
import { AllServiceTypes } from "@/app/store/services";
import { handleWaveEvent } from "@/app/store/wps";
import { RpcApiType } from "@/app/store/wshclientapi";
import { WaveEnv } from "@/app/waveenv/waveenv";
import { PlatformMacOS, PlatformWindows } from "@/util/platformutil";
import { Atom, atom, PrimitiveAtom } from "jotai";
import { Atom, atom, PrimitiveAtom, useAtomValue } from "jotai";
import { DefaultFullConfig } from "./defaultconfig";
import { previewElectronApi } from "./preview-electron-api";
// What works "out of the box" in the mock environment (no MockEnv overrides needed):
//
// RPC calls (handled in makeMockRpc):
// - rpc.EventPublishCommand -- dispatches to handleWaveEvent(); works when the subscriber
// is purely FE-based (registered via WPS on the frontend)
// - rpc.GetMetaCommand -- reads .meta from the mock WOS atom for the given oref
// - rpc.SetMetaCommand -- writes .meta to the mock WOS atom (null values delete keys)
// - rpc.UpdateTabNameCommand -- updates .name on the Tab WaveObj in the mock WOS
// - rpc.UpdateWorkspaceTabIdsCommand -- updates .tabids on the Workspace WaveObj in the mock WOS
//
// Any other RPC call falls through to a console.log and resolves null.
// Override specific calls via MockEnv.rpc (keys are the Command method names, e.g. "GetMetaCommand").
//
// Backend service calls (handled in callBackendService):
// Any call falls through to a console.log and resolves null.
// Override specific calls via MockEnv.services: { Service: { Method: impl } }
// e.g. { "block": { "GetControllerStatus": (blockId) => myStatus } }
type RpcOverrides = {
[K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (...args: any[]) => any;
[K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (...args: any[]) => Promise<any>;
};
type ServiceOverrides = {
[Service: string]: {
[Method: string]: (...args: any[]) => Promise<any>;
};
};
export type MockEnv = {
@ -20,6 +46,7 @@ export type MockEnv = {
platform?: NodeJS.Platform;
settings?: Partial<SettingsType>;
rpc?: RpcOverrides;
services?: ServiceOverrides;
atoms?: Partial<GlobalAtomsType>;
electron?: Partial<ElectronApi>;
createBlock?: WaveEnv["createBlock"];
@ -38,12 +65,23 @@ function mergeRecords<T>(base: Record<string, T>, overrides: Record<string, T>):
}
export function mergeMockEnv(base: MockEnv, overrides: MockEnv): MockEnv {
let mergedServices: ServiceOverrides;
if (base.services != null || overrides.services != null) {
mergedServices = {};
for (const svc of Object.keys(base.services ?? {})) {
mergedServices[svc] = { ...(base.services[svc] ?? {}) };
}
for (const svc of Object.keys(overrides.services ?? {})) {
mergedServices[svc] = { ...(mergedServices[svc] ?? {}), ...(overrides.services[svc] ?? {}) };
}
}
return {
isDev: overrides.isDev ?? base.isDev,
tabId: overrides.tabId ?? base.tabId,
platform: overrides.platform ?? base.platform,
settings: mergeRecords(base.settings, overrides.settings),
rpc: mergeRecords(base.rpc as any, overrides.rpc as any) as RpcOverrides,
services: mergedServices,
atoms: overrides.atoms != null || base.atoms != null ? { ...base.atoms, ...overrides.atoms } : undefined,
electron:
overrides.electron != null || base.electron != null
@ -73,9 +111,10 @@ function makeMockSettingsKeyAtom(
}
function makeMockGlobalAtoms(
settingsOverrides?: Partial<SettingsType>,
atomOverrides?: Partial<GlobalAtomsType>,
tabId?: string
settingsOverrides: Partial<SettingsType>,
atomOverrides: Partial<GlobalAtomsType>,
tabId: string,
getWaveObjectAtom: <T extends WaveObj>(oref: string) => PrimitiveAtom<T>
): GlobalAtomsType {
let fullConfig = DefaultFullConfig;
if (settingsOverrides) {
@ -86,15 +125,28 @@ function makeMockGlobalAtoms(
}
const fullConfigAtom = atom(fullConfig) as PrimitiveAtom<FullConfigType>;
const settingsAtom = atom((get) => get(fullConfigAtom)?.settings ?? {}) as Atom<SettingsType>;
const workspaceIdAtom: Atom<string> = atomOverrides?.workspaceId ?? (atom(null as string) as Atom<string>);
const workspaceAtom: Atom<Workspace> = atom((get) => {
const wsId = get(workspaceIdAtom);
if (wsId == null) {
return null;
}
return get(getWaveObjectAtom<Workspace>("workspace:" + wsId));
});
const defaults: GlobalAtomsType = {
builderId: atom(""),
builderAppId: atom("") as any,
uiContext: atom({ windowid: "", activetabid: tabId ?? "" } as UIContext),
workspace: atom(null as Workspace),
workspaceId: workspaceIdAtom,
workspace: workspaceAtom,
fullConfigAtom,
waveaiModeConfigAtom: atom({}) as any,
settingsAtom,
hasCustomAIPresetsAtom: atom(false),
hasConfigErrors: atom((get) => {
const c = get(fullConfigAtom);
return c?.configerrors != null && c.configerrors.length > 0;
}),
staticTabId: atom(tabId ?? ""),
isFullScreen: atom(false) as any,
zoomFactorAtom: atom(1.0) as any,
@ -110,15 +162,67 @@ function makeMockGlobalAtoms(
if (!atomOverrides) {
return defaults;
}
return { ...defaults, ...atomOverrides };
const merged = { ...defaults, ...atomOverrides };
if (!atomOverrides.workspace) {
merged.workspace = workspaceAtom;
}
return merged;
}
export function makeMockRpc(overrides?: RpcOverrides): RpcApiType {
const dispatchMap = new Map<string, (...args: any[]) => any>();
type MockWosFns = {
getWaveObjectAtom: <T extends WaveObj>(oref: string) => PrimitiveAtom<T>;
mockSetWaveObj: <T extends WaveObj>(oref: string, obj: T) => void;
};
export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiType {
const dispatchMap = new Map<string, (...args: any[]) => Promise<any>>();
dispatchMap.set("eventpublish", async (_client, data: WaveEvent) => {
console.log("[mock eventpublish]", data);
handleWaveEvent(data);
return null;
});
dispatchMap.set("getmeta", async (_client, data: CommandGetMetaData) => {
const objAtom = wos.getWaveObjectAtom(data.oref);
const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType };
return current?.meta ?? {};
});
dispatchMap.set("setmeta", async (_client, data: CommandSetMetaData) => {
const objAtom = wos.getWaveObjectAtom(data.oref);
const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType };
const updatedMeta = { ...(current?.meta ?? {}) };
for (const [key, value] of Object.entries(data.meta)) {
if (value === null) {
delete updatedMeta[key];
} else {
(updatedMeta as any)[key] = value;
}
}
const updated = { ...current, meta: updatedMeta };
wos.mockSetWaveObj(data.oref, updated);
return null;
});
dispatchMap.set("updatetabname", async (_client, data: { args: [string, string] }) => {
const [tabId, newName] = data.args;
const tabORef = "tab:" + tabId;
const objAtom = wos.getWaveObjectAtom(tabORef);
const current = globalStore.get(objAtom) as Tab;
const updated = { ...current, name: newName };
wos.mockSetWaveObj(tabORef, updated);
return null;
});
dispatchMap.set("updateworkspacetabids", async (_client, data: { args: [string, string[]] }) => {
const [workspaceId, tabIds] = data.args;
const wsORef = "workspace:" + workspaceId;
const objAtom = wos.getWaveObjectAtom(wsORef);
const current = globalStore.get(objAtom) as Workspace;
const updated = { ...current, tabids: tabIds };
wos.mockSetWaveObj(wsORef, updated);
return null;
});
if (overrides) {
for (const key of Object.keys(overrides) as (keyof RpcOverrides)[]) {
const cmdName = key.slice(0, -"Command".length).toLowerCase();
dispatchMap.set(cmdName, overrides[key] as (...args: any[]) => any);
dispatchMap.set(cmdName, overrides[key] as (...args: any[]) => Promise<any>);
}
}
const rpc = new RpcApiType();
@ -134,7 +238,7 @@ export function makeMockRpc(overrides?: RpcOverrides): RpcApiType {
async *mockWshRpcStream(_client, command, data, _opts) {
const fn = dispatchMap.get(command);
if (fn) {
yield* fn(_client, data, _opts);
yield await fn(_client, data, _opts);
return;
}
console.log("[mock rpc stream]", command, data);
@ -154,10 +258,18 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv {
const overrides: MockEnv = mockEnv ?? {};
const platform = overrides.platform ?? PlatformMacOS;
const connStatusAtomCache = new Map<string, PrimitiveAtom<ConnStatus>>();
const waveObjectAtomCache = new Map<string, Atom<any>>();
const waveObjectValueAtomCache = new Map<string, PrimitiveAtom<any>>();
const waveObjectDerivedAtomCache = new Map<string, Atom<any>>();
const blockMetaKeyAtomCache = new Map<string, Atom<any>>();
const connConfigKeyAtomCache = new Map<string, Atom<any>>();
const atoms = makeMockGlobalAtoms(overrides.settings, overrides.atoms, overrides.tabId);
const getWaveObjectAtom = <T extends WaveObj>(oref: string): PrimitiveAtom<T> => {
if (!waveObjectValueAtomCache.has(oref)) {
const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T;
waveObjectValueAtomCache.set(oref, atom(obj) as PrimitiveAtom<T>);
}
return waveObjectValueAtomCache.get(oref) as PrimitiveAtom<T>;
};
const atoms = makeMockGlobalAtoms(overrides.settings, overrides.atoms, overrides.tabId, getWaveObjectAtom);
const localHostDisplayNameAtom = atom<string>((get) => {
const configValue = get(atoms.settingsAtom)?.["conn:localhostdisplayname"];
if (configValue != null) {
@ -165,14 +277,24 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv {
}
return "user@localhost";
});
const mockWosFns: MockWosFns = {
getWaveObjectAtom,
mockSetWaveObj: <T extends WaveObj>(oref: string, obj: T) => {
if (!waveObjectValueAtomCache.has(oref)) {
waveObjectValueAtomCache.set(oref, atom(null as WaveObj));
}
globalStore.set(waveObjectValueAtomCache.get(oref), obj);
},
};
const env = {
isMock: true,
mockEnv: overrides,
electron: {
...previewElectronApi,
getPlatform: () => platform,
...overrides.electron,
},
rpc: makeMockRpc(overrides.rpc),
rpc: makeMockRpc(overrides.rpc, mockWosFns),
atoms,
getSettingsKeyAtom: makeMockSettingsKeyAtom(atoms.settingsAtom, overrides.settings),
platform,
@ -201,34 +323,27 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv {
return connStatusAtomCache.get(conn);
},
wos: {
getWaveObjectAtom: <T extends WaveObj>(oref: string) => {
const cacheKey = oref + ":value";
if (!waveObjectAtomCache.has(cacheKey)) {
const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T;
waveObjectAtomCache.set(cacheKey, atom(obj));
}
return waveObjectAtomCache.get(cacheKey) as PrimitiveAtom<T>;
},
getWaveObjectAtom: mockWosFns.getWaveObjectAtom,
getWaveObjectLoadingAtom: (oref: string) => {
const cacheKey = oref + ":loading";
if (!waveObjectAtomCache.has(cacheKey)) {
waveObjectAtomCache.set(cacheKey, atom(false));
if (!waveObjectDerivedAtomCache.has(cacheKey)) {
waveObjectDerivedAtomCache.set(cacheKey, atom(false));
}
return waveObjectAtomCache.get(cacheKey) as Atom<boolean>;
return waveObjectDerivedAtomCache.get(cacheKey) as Atom<boolean>;
},
isWaveObjectNullAtom: (oref: string) => {
const cacheKey = oref + ":isnull";
if (!waveObjectAtomCache.has(cacheKey)) {
waveObjectAtomCache.set(
if (!waveObjectDerivedAtomCache.has(cacheKey)) {
waveObjectDerivedAtomCache.set(
cacheKey,
atom((get) => get(env.wos.getWaveObjectAtom(oref)) == null)
);
}
return waveObjectAtomCache.get(cacheKey) as Atom<boolean>;
return waveObjectDerivedAtomCache.get(cacheKey) as Atom<boolean>;
},
useWaveObjectValue: <T extends WaveObj>(oref: string): [T, boolean] => {
const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T;
return [obj, false];
const objAtom = env.wos.getWaveObjectAtom<T>(oref);
return [useAtomValue(objAtom), false];
},
},
getBlockMetaKeyAtom: <T extends keyof MetaType>(blockId: string, key: T) => {
@ -255,10 +370,20 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv {
}
return connConfigKeyAtomCache.get(cacheKey) as Atom<ConnKeywords[T]>;
},
mockTabModel: null as TabModel,
services: null as any,
callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => {
const fn = overrides.services?.[service]?.[method];
if (fn) {
return fn(...args);
}
console.log("[mock callBackendService]", service, method, args, noUIContext);
return Promise.resolve(null);
},
mockSetWaveObj: mockWosFns.mockSetWaveObj,
mockModels: new Map<any, any>(),
} as MockWaveEnv;
if (overrides.tabId != null) {
env.mockTabModel = new TabModel(overrides.tabId, env);
}
env.services = Object.fromEntries(
Object.entries(AllServiceTypes).map(([key, ServiceClass]) => [key, new ServiceClass(env)])
) as any;
return env;
}

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import Logo from "@/app/asset/logo.svg";
import { ErrorBoundary } from "@/app/element/errorboundary";
import { getAtoms, initGlobalAtoms } from "@/app/store/global-atoms";
import { GlobalModel } from "@/app/store/global-model";
import { globalStore } from "@/app/store/jotaiStore";
@ -13,6 +14,7 @@ import { createRoot } from "react-dom/client";
import { makeMockWaveEnv } from "./mock/mockwaveenv";
import { installPreviewElectronApi } from "./mock/preview-electron-api";
import "overlayscrollbars/overlayscrollbars.css";
import "../app/app.scss";
// preview.css should come *after* app.scss (don't remove the newline above otherwise prettier will reorder these imports)
@ -95,6 +97,7 @@ function PreviewRoot() {
atoms: {
uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext),
staticTabId: atom(PreviewTabId),
workspaceId: atom(PreviewWorkspaceId),
},
})
);
@ -118,9 +121,11 @@ function PreviewApp() {
<>
<PreviewHeader previewName={previewName} />
<div className="h-screen overflow-y-auto bg-background text-foreground font-sans flex flex-col items-center pt-12 pb-8">
<Suspense fallback={null}>
<PreviewComponent />
</Suspense>
<ErrorBoundary>
<Suspense fallback={null}>
<PreviewComponent />
</Suspense>
</ErrorBoundary>
</div>
</>
);
@ -143,6 +148,7 @@ function PreviewApp() {
const PreviewTabId = crypto.randomUUID();
const PreviewWindowId = crypto.randomUUID();
const PreviewWorkspaceId = crypto.randomUUID();
const PreviewClientId = crypto.randomUUID();
function initPreview() {

View file

@ -0,0 +1,306 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { loadBadges, LoadBadgesEnv } from "@/app/store/badge";
import { globalStore } from "@/app/store/jotaiStore";
import { TabBar } from "@/app/tab/tabbar";
import { TabBarEnv } from "@/app/tab/tabbarenv";
import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv";
import { applyMockEnvOverrides, MockWaveEnv } from "@/preview/mock/mockwaveenv";
import { PlatformLinux, PlatformMacOS, PlatformWindows } from "@/util/platformutil";
import { atom, useAtom, useAtomValue } from "jotai";
import { CSSProperties, useEffect, useMemo, useRef, useState } from "react";
type PreviewTabEntry = {
tabId: string;
tabName: string;
badges?: Badge[] | null;
flagColor?: string | null;
};
function badgeBlockId(tabId: string, badgeId: string): string {
return `${tabId}-badge-${badgeId}`;
}
function makeTabWaveObj(tab: PreviewTabEntry): Tab {
const blockids = (tab.badges ?? []).map((b) => badgeBlockId(tab.tabId, b.badgeid));
return {
otype: "tab",
oid: tab.tabId,
version: 1,
name: tab.tabName,
blockids,
meta: tab.flagColor ? { "tab:flagcolor": tab.flagColor } : {},
} as Tab;
}
function makeMockBadgeEvents(): BadgeEvent[] {
const events: BadgeEvent[] = [];
for (const tab of InitialTabs) {
for (const badge of tab.badges ?? []) {
events.push({ oref: `block:${badgeBlockId(tab.tabId, badge.badgeid)}`, badge });
}
}
return events;
}
const MockWorkspaceId = "preview-workspace-1";
const InitialTabs: PreviewTabEntry[] = [
{ tabId: "preview-tab-1", tabName: "Terminal" },
{
tabId: "preview-tab-2",
tabName: "Build Logs",
badges: [
{
badgeid: "01958000-0000-7000-0000-000000000001",
icon: "triangle-exclamation",
color: "#f59e0b",
priority: 2,
},
],
},
{
tabId: "preview-tab-3",
tabName: "Deploy",
badges: [
{ badgeid: "01958000-0000-7000-0000-000000000002", icon: "circle-check", color: "#4ade80", priority: 3 },
],
flagColor: "#429dff",
},
{
tabId: "preview-tab-4",
tabName: "A Very Long Tab Name To Show Truncation",
badges: [
{ badgeid: "01958000-0000-7000-0000-000000000003", icon: "bell", color: "#f87171", priority: 2 },
{ badgeid: "01958000-0000-7000-0000-000000000004", icon: "circle-small", color: "#fbbf24", priority: 1 },
],
},
{ tabId: "preview-tab-5", tabName: "Wave AI" },
{ tabId: "preview-tab-6", tabName: "Preview", flagColor: "#bf55ec" },
];
const MockConfigErrors: ConfigError[] = [
{ file: "~/.waveterm/config.json", err: 'unknown preset "bg@aurora"' },
{ file: "~/.waveterm/settings.json", err: "invalid color for tab theme" },
];
function makeMockWorkspace(tabIds: string[]): Workspace {
return {
otype: "workspace",
oid: MockWorkspaceId,
version: 1,
name: "Preview Workspace",
tabids: tabIds,
activetabid: tabIds[1] ?? tabIds[0] ?? "",
meta: {},
} as Workspace;
}
export function TabBarPreview() {
const baseEnv = useWaveEnv();
const initialTabIds = InitialTabs.map((t) => t.tabId);
const envRef = useRef<MockWaveEnv>(null);
const [platform, setPlatform] = useState<NodeJS.Platform>(PlatformMacOS);
const tabEnv = useMemo(() => {
const mockWaveObjs: Record<string, WaveObj> = {
[`workspace:${MockWorkspaceId}`]: makeMockWorkspace(initialTabIds),
};
for (const tab of InitialTabs) {
mockWaveObjs[`tab:${tab.tabId}`] = makeTabWaveObj(tab);
}
const env = applyMockEnvOverrides(baseEnv, {
tabId: InitialTabs[1].tabId,
platform,
mockWaveObjs,
atoms: {
workspaceId: atom(MockWorkspaceId),
staticTabId: atom(InitialTabs[1].tabId),
},
rpc: {
GetAllBadgesCommand: () => Promise.resolve(makeMockBadgeEvents()),
},
electron: {
createTab: () => {
const e = envRef.current;
if (e == null) return;
const newTabId = `preview-tab-${crypto.randomUUID()}`;
e.mockSetWaveObj(`tab:${newTabId}`, {
otype: "tab",
oid: newTabId,
version: 1,
name: "New Tab",
blockids: [],
meta: {},
} as Tab);
const ws = globalStore.get(e.wos.getWaveObjectAtom<Workspace>(`workspace:${MockWorkspaceId}`));
e.mockSetWaveObj(`workspace:${MockWorkspaceId}`, {
...ws,
tabids: [...(ws.tabids ?? []), newTabId],
});
globalStore.set(e.atoms.staticTabId as any, newTabId);
},
closeTab: (_workspaceId: string, tabId: string) => {
const e = envRef.current;
if (e == null) return Promise.resolve(false);
const ws = globalStore.get(e.wos.getWaveObjectAtom<Workspace>(`workspace:${MockWorkspaceId}`));
const newTabIds = (ws.tabids ?? []).filter((id) => id !== tabId);
if (newTabIds.length === 0) {
return Promise.resolve(false);
}
e.mockSetWaveObj(`workspace:${MockWorkspaceId}`, { ...ws, tabids: newTabIds });
if (globalStore.get(e.atoms.staticTabId) === tabId) {
globalStore.set(e.atoms.staticTabId as any, newTabIds[0]);
}
return Promise.resolve(true);
},
setActiveTab: (tabId: string) => {
const e = envRef.current;
if (e == null) return;
globalStore.set(e.atoms.staticTabId as any, tabId);
},
showWorkspaceAppMenu: () => {
console.log("[preview] showWorkspaceAppMenu");
},
},
});
envRef.current = env;
return env;
}, [platform]);
return (
<WaveEnvContext.Provider value={tabEnv}>
<TabBarPreviewInner platform={platform} setPlatform={setPlatform} />
</WaveEnvContext.Provider>
);
}
type TabBarPreviewInnerProps = {
platform: NodeJS.Platform;
setPlatform: (platform: NodeJS.Platform) => void;
};
function TabBarPreviewInner({ platform, setPlatform }: TabBarPreviewInnerProps) {
const env = useWaveEnv<TabBarEnv>();
const loadBadgesEnv = useWaveEnv<LoadBadgesEnv>();
const [showConfigErrors, setShowConfigErrors] = useState(false);
const [hideAiButton, setHideAiButton] = useState(false);
const [showMenuBar, setShowMenuBar] = useState(false);
const [isFullScreen, setIsFullScreen] = useAtom(env.atoms.isFullScreen);
const [zoomFactor, setZoomFactor] = useAtom(env.atoms.zoomFactorAtom);
const [fullConfig, setFullConfig] = useAtom(env.atoms.fullConfigAtom);
const [updaterStatus, setUpdaterStatus] = useAtom(env.atoms.updaterStatusAtom);
const workspace = useAtomValue(env.wos.getWaveObjectAtom<Workspace>(`workspace:${MockWorkspaceId}`));
useEffect(() => {
loadBadges(loadBadgesEnv);
}, []);
useEffect(() => {
setFullConfig((prev) => ({
...(prev ?? ({} as FullConfigType)),
settings: {
...(prev?.settings ?? {}),
"app:hideaibutton": hideAiButton,
"window:showmenubar": showMenuBar,
},
configerrors: showConfigErrors ? MockConfigErrors : [],
}));
}, [hideAiButton, showMenuBar, setFullConfig, showConfigErrors]);
return (
<div className="flex w-full flex-col gap-6">
<div className="grid gap-4 rounded-md border border-border bg-panel p-4 md:grid-cols-3 mx-6 mt-6">
<label className="flex flex-col gap-2 text-xs text-muted">
<span>Platform</span>
<select
value={platform}
onChange={(event) => setPlatform(event.target.value as NodeJS.Platform)}
className="rounded border border-border bg-background px-2 py-1 text-foreground cursor-pointer"
>
<option value={PlatformMacOS}>macOS</option>
<option value={PlatformWindows}>Windows</option>
<option value={PlatformLinux}>Linux</option>
</select>
</label>
<label className="flex flex-col gap-2 text-xs text-muted">
<span>Updater banner</span>
<select
value={updaterStatus}
onChange={(event) => setUpdaterStatus(event.target.value as UpdaterStatus)}
className="rounded border border-border bg-background px-2 py-1 text-foreground"
>
<option value="up-to-date">Hidden</option>
<option value="ready">Update Available</option>
<option value="downloading">Downloading</option>
<option value="installing">Installing</option>
<option value="error">Error</option>
</select>
</label>
<label className="flex items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={showConfigErrors}
onChange={(event) => setShowConfigErrors(event.target.checked)}
className="cursor-pointer"
/>
Show config error button
</label>
<label className="flex items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={hideAiButton}
onChange={(event) => setHideAiButton(event.target.checked)}
className="cursor-pointer"
/>
Hide Wave AI button
</label>
<label className="flex items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={showMenuBar}
onChange={(event) => setShowMenuBar(event.target.checked)}
className="cursor-pointer"
/>
Show menu bar
</label>
<label className="flex items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={isFullScreen}
onChange={(event) => setIsFullScreen(event.target.checked)}
className="cursor-pointer"
/>
Full screen
</label>
<label className="flex flex-col gap-2 text-xs text-muted">
<span>Zoom factor: {zoomFactor.toFixed(2)}</span>
<input
type="range"
min={0.8}
max={1.5}
step={0.05}
value={zoomFactor}
onChange={(event) => setZoomFactor(Number(event.target.value))}
className="cursor-pointer"
/>
</label>
<div className="flex items-end text-xs text-muted">
Double-click a tab name to rename it. Close/add buttons and drag reordering are fully functional.
</div>
</div>
<div
className="w-full border-y border-border shadow-xl overflow-hidden"
style={{ "--zoomfactor-inv": zoomFactor > 0 ? 1 / zoomFactor : 1 } as CSSProperties}
>
{workspace != null && <TabBar key={platform} workspace={workspace} />}
</div>
<div className="mx-6 mb-6 text-xs text-muted">
Tabs: {workspace?.tabids?.length ?? 0} · Config errors: {fullConfig?.configerrors?.length ?? 0}
</div>
</div>
);
}
TabBarPreviewInner.displayName = "TabBarPreviewInner";

View file

@ -7,7 +7,6 @@ import { atom, useAtom } from "jotai";
import { useRef } from "react";
import { applyMockEnvOverrides } from "../mock/mockwaveenv";
const workspaceAtom = atom<Workspace>(null as Workspace);
const resizableHeightAtom = atom(250);
function makeMockApp(name: string, icon: string, iconcolor: string): AppInfo {
@ -91,7 +90,6 @@ function makeWidgetsEnv(baseEnv: WaveEnv, isDev: boolean, hasCustomAIPresets: bo
rpc: { ListAllAppsCommand: () => Promise.resolve(apps ?? []) },
atoms: {
fullConfigAtom,
workspace: workspaceAtom,
hasCustomAIPresetsAtom: atom(hasCustomAIPresets),
},
});

View file

@ -11,11 +11,13 @@ declare global {
builderId: jotai.Atom<string>; // readonly (for builder mode)
builderAppId: jotai.PrimitiveAtom<string>; // app being edited in builder mode
uiContext: jotai.Atom<UIContext>; // driven from windowId, tabId
workspace: jotai.Atom<Workspace>; // driven from WOS
workspaceId: jotai.Atom<string>; // derived from window WOS object
workspace: jotai.Atom<Workspace>; // driven from workspaceId via WOS
fullConfigAtom: jotai.PrimitiveAtom<FullConfigType>; // driven from WOS, settings -- updated via WebSocket
waveaiModeConfigAtom: jotai.PrimitiveAtom<Record<string, AIModeConfigType>>; // resolved AI mode configs -- updated via WebSocket
settingsAtom: jotai.Atom<SettingsType>; // derrived from fullConfig
hasCustomAIPresetsAtom: jotai.Atom<boolean>; // derived from fullConfig
hasConfigErrors: jotai.Atom<boolean>; // derived from fullConfig
staticTabId: jotai.Atom<string>;
isFullScreen: jotai.PrimitiveAtom<boolean>;
zoomFactorAtom: jotai.PrimitiveAtom<number>;

View file

@ -3,6 +3,7 @@
export const PlatformMacOS = "darwin";
export const PlatformWindows = "win32";
export const PlatformLinux = "linux";
export let PLATFORM: NodeJS.Platform = PlatformMacOS;
export function setPlatform(platform: NodeJS.Platform) {

View file

@ -72,23 +72,6 @@ func (svc *ObjectService) GetObjects(orefStrArr []string) ([]waveobj.WaveObj, er
return wstore.DBSelectORefs(ctx, orefArr)
}
func (svc *ObjectService) UpdateTabName_Meta() tsgenmeta.MethodMeta {
return tsgenmeta.MethodMeta{
ArgNames: []string{"uiContext", "tabId", "name"},
}
}
func (svc *ObjectService) UpdateTabName(uiContext waveobj.UIContext, tabId, name string) (waveobj.UpdatesRtnType, error) {
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn()
ctx = waveobj.ContextWithUpdates(ctx)
err := wstore.UpdateTabName(ctx, tabId, name)
if err != nil {
return nil, fmt.Errorf("error updating tab name: %w", err)
}
return waveobj.ContextGetUpdatesRtn(ctx), nil
}
func (svc *ObjectService) CreateBlock_Meta() tsgenmeta.MethodMeta {
return tsgenmeta.MethodMeta{
ArgNames: []string{"uiContext", "blockDef", "rtOpts"},

View file

@ -6,7 +6,6 @@ package workspaceservice
import (
"context"
"fmt"
"log"
"time"
"github.com/wavetermdev/waveterm/pkg/blockcontroller"
@ -165,24 +164,6 @@ func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activ
return tabId, updates, nil
}
func (svc *WorkspaceService) UpdateTabIds_Meta() tsgenmeta.MethodMeta {
return tsgenmeta.MethodMeta{
ArgNames: []string{"uiContext", "workspaceId", "tabIds"},
}
}
func (svc *WorkspaceService) UpdateTabIds(uiContext waveobj.UIContext, workspaceId string, tabIds []string) (waveobj.UpdatesRtnType, error) {
log.Printf("UpdateTabIds %s %v\n", workspaceId, tabIds)
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn()
ctx = waveobj.ContextWithUpdates(ctx)
err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds)
if err != nil {
return nil, fmt.Errorf("error updating workspace tab ids: %w", err)
}
return waveobj.ContextGetUpdatesRtn(ctx), nil
}
func (svc *WorkspaceService) SetActiveTab_Meta() tsgenmeta.MethodMeta {
return tsgenmeta.MethodMeta{
ArgNames: []string{"workspaceId", "tabId"},

View file

@ -412,7 +412,7 @@ func GenerateMethodSignature(serviceName string, method reflect.Method, meta tsg
}
func GenerateMethodBody(serviceName string, method reflect.Method, meta tsgenmeta.MethodMeta) string {
return fmt.Sprintf(" return WOS.callBackendService(%q, %q, Array.from(arguments))\n", serviceName, method.Name)
return fmt.Sprintf(" return callBackendService(this.waveEnv, %q, %q, Array.from(arguments))\n", serviceName, method.Name)
}
func GenerateServiceClass(serviceName string, serviceObj any, tsTypesMap map[reflect.Type]string) string {
@ -420,9 +420,13 @@ func GenerateServiceClass(serviceName string, serviceObj any, tsTypesMap map[ref
var sb strings.Builder
tsServiceName := serviceType.Elem().Name()
sb.WriteString(fmt.Sprintf("// %s (%s)\n", serviceType.Elem().String(), serviceName))
sb.WriteString("class ")
sb.WriteString("export class ")
sb.WriteString(tsServiceName + "Type")
sb.WriteString(" {\n")
sb.WriteString(" waveEnv: WaveEnv;\n\n")
sb.WriteString(" constructor(waveEnv?: WaveEnv) {\n")
sb.WriteString(" this.waveEnv = waveEnv;\n")
sb.WriteString(" }\n\n")
isFirst := true
for midx := 0; midx < serviceType.NumMethod(); midx++ {
method := serviceType.Method(midx)

View file

@ -921,6 +921,18 @@ func TestMultiArgCommand(w *wshutil.WshRpc, arg1 string, arg2 int, arg3 bool, op
return resp, err
}
// command "updatetabname", wshserver.UpdateTabNameCommand
func UpdateTabNameCommand(w *wshutil.WshRpc, arg1 string, arg2 string, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "updatetabname", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts)
return err
}
// command "updateworkspacetabids", wshserver.UpdateWorkspaceTabIdsCommand
func UpdateWorkspaceTabIdsCommand(w *wshutil.WshRpc, arg1 string, arg2 []string, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "updateworkspacetabids", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts)
return err
}
// command "vdomasyncinitiation", wshserver.VDomAsyncInitiationCommand
func VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiationRequest, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "vdomasyncinitiation", data, opts)

View file

@ -94,6 +94,8 @@ type WshRpcInterface interface {
FetchSuggestionsCommand(ctx context.Context, data FetchSuggestionsData) (*FetchSuggestionsResponse, error)
DisposeSuggestionsCommand(ctx context.Context, widgetId string) error
GetTabCommand(ctx context.Context, tabId string) (*waveobj.Tab, error)
UpdateTabNameCommand(ctx context.Context, tabId string, newName string) error
UpdateWorkspaceTabIdsCommand(ctx context.Context, workspaceId string, tabIds []string) error
GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error)
// connection functions

View file

@ -160,6 +160,26 @@ func (ws *WshServer) GetMetaCommand(ctx context.Context, data wshrpc.CommandGetM
return waveobj.GetMeta(obj), nil
}
func (ws *WshServer) UpdateTabNameCommand(ctx context.Context, tabId string, newName string) error {
oref := waveobj.ORef{OType: waveobj.OType_Tab, OID: tabId}
err := wstore.UpdateTabName(ctx, tabId, newName)
if err != nil {
return fmt.Errorf("error updating tab name: %w", err)
}
wcore.SendWaveObjUpdate(oref)
return nil
}
func (ws *WshServer) UpdateWorkspaceTabIdsCommand(ctx context.Context, workspaceId string, tabIds []string) error {
oref := waveobj.ORef{OType: waveobj.OType_Workspace, OID: workspaceId}
err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds)
if err != nil {
return fmt.Errorf("error updating workspace tab ids: %w", err)
}
wcore.SendWaveObjUpdate(oref)
return nil
}
func (ws *WshServer) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error {
log.Printf("SetMetaCommand: %s | %v\n", data.ORef, data.Meta)
oref := data.ORef

View file

@ -22,7 +22,8 @@
"@/store/*": ["frontend/app/store/*"],
"@/view/*": ["frontend/app/view/*"],
"@/element/*": ["frontend/app/element/*"],
"@/shadcn/*": ["frontend/app/shadcn/*"]
"@/shadcn/*": ["frontend/app/shadcn/*"],
"@/preview/*": ["frontend/preview/*"]
},
"lib": ["dom", "dom.iterable", "es6"],
"allowJs": true,