From f9d006b97b4d8d823cb92313fe214891c2c295ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:15:24 +0000 Subject: [PATCH 01/30] Initial plan From dcd00f3a5bd3ac171cb7f0aa50d89ccfc8970bc5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:27:08 +0000 Subject: [PATCH 02/30] feat: add tabbar preview env Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/app/tab/tabbar.tsx | 45 +-- frontend/app/tab/tabbarenv.ts | 24 ++ frontend/preview/previews/tabbar.preview.tsx | 372 +++++++++++++++++++ 3 files changed, 420 insertions(+), 21 deletions(-) create mode 100644 frontend/app/tab/tabbarenv.ts create mode 100644 frontend/preview/previews/tabbar.preview.tsx diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index 31711c354c..1b037ba4af 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -5,9 +5,8 @@ import { Button } from "@/app/element/button"; import { Tooltip } from "@/app/element/tooltip"; import { modalsModel } from "@/app/store/modalmodel"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; 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"; @@ -17,6 +16,7 @@ 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 +44,9 @@ interface TabBarProps { } const WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject }) => { + const env = useWaveEnv(); 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 +74,8 @@ const WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject { - const fullConfig = useAtomValue(atoms.fullConfigAtom); + const env = useWaveEnv(); + const fullConfig = useAtomValue(env.atoms.fullConfigAtom); if (fullConfig?.configerrors == null || fullConfig?.configerrors.length == 0) { return ( @@ -109,7 +111,8 @@ const ConfigErrorMessage = () => { }; const ConfigErrorIcon = ({ buttonRef }: { buttonRef: React.RefObject }) => { - const fullConfig = useAtomValue(atoms.fullConfigAtom); + const env = useWaveEnv(); + const fullConfig = useAtomValue(env.atoms.fullConfigAtom); function handleClick() { modalsModel.pushModal("MessageModal", { children: }); @@ -150,6 +153,7 @@ function strArrayIsEqual(a: string[], b: string[]) { } const TabBar = memo(({ workspace }: TabBarProps) => { + const env = useWaveEnv(); const [tabIds, setTabIds] = useState([]); const [dragStartPositions, setDragStartPositions] = useState([]); const [draggingTab, setDraggingTab] = useState(); @@ -183,10 +187,10 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const updateStatusBannerRef = useRef(null); const configErrorButtonRef = useRef(null); const prevAllLoadedRef = useRef(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 settings = useAtomValue(env.atoms.settingsAtom); let prevDelta: number; let prevDragDirection: string; @@ -306,7 +310,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { saveTabsPositionDebounced(); }, [tabIds, newTabId, isFullScreen]); - const reinitVersion = useAtomValue(atoms.reinitVersion); + const reinitVersion = useAtomValue(env.atoms.reinitVersion); useEffect(() => { if (reinitVersion > 0) { setSizeAndPosition(); @@ -547,7 +551,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const handleSelectTab = (tabId: string) => { if (!draggingTabDataRef.current.dragged) { - setActiveTab(tabId); + env.electron.setActiveTab(tabId); } }; @@ -569,7 +573,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 +583,9 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const handleCloseTab = (event: React.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) + const confirmClose = settings["tab:confirmclose"] ?? false; + env.electron + .closeTab(workspace.oid, tabId, confirmClose) .then((didClose) => { if (didClose) { tabsWrapperRef.current?.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease"); @@ -607,15 +610,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() && !settings["window: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 +628,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 { @@ -704,4 +707,4 @@ const TabBar = memo(({ workspace }: TabBarProps) => { ); }); -export { TabBar }; +export { ConfigErrorIcon, ConfigErrorMessage, TabBar, WaveAIButton }; diff --git a/frontend/app/tab/tabbarenv.ts b/frontend/app/tab/tabbarenv.ts new file mode 100644 index 0000000000..a08a0ee465 --- /dev/null +++ b/frontend/app/tab/tabbarenv.ts @@ -0,0 +1,24 @@ +// 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"]; + }; + atoms: { + fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; + staticTabId: WaveEnv["atoms"]["staticTabId"]; + isFullScreen: WaveEnv["atoms"]["isFullScreen"]; + zoomFactorAtom: WaveEnv["atoms"]["zoomFactorAtom"]; + settingsAtom: WaveEnv["atoms"]["settingsAtom"]; + reinitVersion: WaveEnv["atoms"]["reinitVersion"]; + }; + getSettingsKeyAtom: SettingsKeyAtomFnType<"app:hideaibutton" | "tab:confirmclose">; + isWindows: WaveEnv["isWindows"]; + isMacOS: WaveEnv["isMacOS"]; +}>; diff --git a/frontend/preview/previews/tabbar.preview.tsx b/frontend/preview/previews/tabbar.preview.tsx new file mode 100644 index 0000000000..de5b85f7cb --- /dev/null +++ b/frontend/preview/previews/tabbar.preview.tsx @@ -0,0 +1,372 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import WorkspaceSVG from "@/app/asset/workspace.svg"; +import { Tooltip } from "@/app/element/tooltip"; +import { IconButton } from "@/app/element/iconbutton"; +import { getAtoms } from "@/app/store/global-atoms"; +import { TabV } from "@/app/tab/tab"; +import { ConfigErrorIcon, WaveAIButton } from "@/app/tab/tabbar"; +import { TabBarEnv } from "@/app/tab/tabbarenv"; +import { UpdateStatusBanner } from "@/app/tab/updatebanner"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { useAtom } from "jotai"; +import { CSSProperties, useEffect, useMemo, useRef, useState } from "react"; + +type PreviewTabEntry = { + tabId: string; + tabName: string; + badges?: Badge[] | null; + flagColor?: string | null; +}; + +const TabDefaultWidth = 130; +const TabMinWidth = 100; +const TabHeight = 26; +const MockConfigErrors: ConfigError[] = [ + { file: "~/.waveterm/config.json", err: "unknown preset \"bg@aurora\"" }, + { file: "~/.waveterm/settings.json", err: "invalid color for tab theme" }, +]; +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" }, +]; + +function shouldShowAppMenuButton(platform: NodeJS.Platform, showMenuBar: boolean): boolean { + return platform === "win32" || (platform !== "darwin" && !showMenuBar); +} + +function getWindowDragWidths(platform: NodeJS.Platform, isFullScreen: boolean, zoomFactor: number) { + let windowDragLeftWidth = 10; + if (platform === "darwin" && !isFullScreen) { + windowDragLeftWidth = zoomFactor > 0 ? 74 / zoomFactor : 74; + } + + let windowDragRightWidth = 12; + if (platform === "win32") { + windowDragRightWidth = zoomFactor > 0 ? 139 / zoomFactor : 139; + } + + return { windowDragLeftWidth, windowDragRightWidth }; +} + +function MockWorkspaceSwitcher({ divRef }: { divRef: React.RefObject }) { + return ( + +
+ +
+
+ ); +} + +function MockTabStrip({ + tabs, + activeTabId, + availableWidth, + onSelectTab, + onCloseTab, + onRenameTab, +}: { + tabs: PreviewTabEntry[]; + activeTabId: string; + availableWidth: number; + onSelectTab: (tabId: string) => void; + onCloseTab: (tabId: string) => void; + onRenameTab: (tabId: string, newName: string) => void; +}) { + const tabRefs = useRef>({}); + const tabWidth = useMemo(() => { + if (tabs.length === 0) { + return TabDefaultWidth; + } + return Math.max(TabMinWidth, Math.min(availableWidth / tabs.length, TabDefaultWidth)); + }, [availableWidth, tabs.length]); + + useEffect(() => { + tabs.forEach((tab, index) => { + const el = tabRefs.current[tab.tabId]; + if (el == null) { + return; + } + el.style.width = `${tabWidth}px`; + el.style.opacity = "1"; + el.style.transform = `translate3d(${index * tabWidth}px, 0, 0)`; + }); + }, [tabWidth, tabs]); + + return ( +
+
+ {tabs.map((tab, index) => { + const activeIndex = tabs.findIndex((item) => item.tabId === activeTabId); + const isActive = tab.tabId === activeTabId; + const showDivider = index !== 0 && !isActive && index !== activeIndex + 1; + return ( + { + tabRefs.current[tab.tabId] = el; + }} + tabId={tab.tabId} + tabName={tab.tabName} + active={isActive} + showDivider={showDivider} + isDragging={false} + tabWidth={tabWidth} + isNew={false} + badges={tab.badges ?? null} + flagColor={tab.flagColor ?? null} + onClick={() => onSelectTab(tab.tabId)} + onClose={() => onCloseTab(tab.tabId)} + onDragStart={() => {}} + onContextMenu={() => {}} + onRename={(newName) => onRenameTab(tab.tabId, newName)} + /> + ); + })} +
+
+ ); +} + +export function TabBarPreview() { + const env = useWaveEnv(); + const [tabs, setTabs] = useState(InitialTabs); + const [activeTabId, setActiveTabId] = useState(InitialTabs[1].tabId); + const [frameWidth, setFrameWidth] = useState(1180); + const [platform, setPlatform] = useState("darwin"); + const [showMenuBar, setShowMenuBar] = useState(false); + const [showConfigErrors, setShowConfigErrors] = useState(true); + const [hideAiButton, setHideAiButton] = 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(getAtoms().updaterStatusAtom); + const workspaceSwitcherRef = useRef(null); + const waveAIButtonRef = useRef(null); + const updateStatusBannerRef = useRef(null); + const configErrorButtonRef = useRef(null); + + useEffect(() => { + setFullConfig((prev) => ({ + ...(prev ?? ({} as FullConfigType)), + settings: { + ...(prev?.settings ?? {}), + "app:hideaibutton": hideAiButton, + "window:showmenubar": showMenuBar, + }, + configerrors: showConfigErrors ? MockConfigErrors : [], + })); + }, [hideAiButton, setFullConfig, showConfigErrors, showMenuBar]); + + const showAppMenuButton = shouldShowAppMenuButton(platform, showMenuBar); + const { windowDragLeftWidth, windowDragRightWidth } = getWindowDragWidths(platform, isFullScreen, zoomFactor); + const tabsAvailableWidth = + frameWidth - + windowDragLeftWidth - + windowDragRightWidth - + (showAppMenuButton ? 28 : 0) - + (hideAiButton ? 0 : 48) - + 42 - + 44 - + (updaterStatus === "up-to-date" ? 0 : 164) - + (showConfigErrors ? 132 : 0) - + 24; + + return ( +
+
+ + + + + + + + +
+ Double-click a tab name to rename it. Close buttons and context menus are mocked for preview use. +
+
+ +
0 ? 1 / zoomFactor : 1, + } as CSSProperties + } + > +
+
+ {showAppMenuButton && ( +
+ +
+ )} + + +
+ { + setTabs((prevTabs) => { + const nextTabs = prevTabs.filter((tab) => tab.tabId !== tabId); + if (nextTabs.length === 0) { + return prevTabs; + } + if (activeTabId === tabId) { + setActiveTabId(nextTabs[0].tabId); + } + return nextTabs; + }); + }} + onRenameTab={(tabId, newName) => { + setTabs((prevTabs) => + prevTabs.map((tab) => (tab.tabId === tabId ? { ...tab, tabName: newName } : tab)) + ); + }} + /> +
+ { + const tabId = `preview-tab-${crypto.randomUUID()}`; + const nextTab = { tabId, tabName: "New Tab" }; + setTabs((prevTabs) => [...prevTabs, nextTab]); + setActiveTabId(tabId); + }, + }} + /> +
+ + +
+
+
+
+ +
+ Tabs: {tabs.length} · Active tab: {activeTabId} · Config errors: {fullConfig?.configerrors?.length ?? 0} +
+
+ ); +} From fa1bf060ca985dc5c84236ed3e9bccdc7e9e4326 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:36:39 +0000 Subject: [PATCH 03/30] chore: polish tabbar preview Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/app/tab/tabbar.tsx | 2 +- frontend/preview/previews/tabbar.preview.tsx | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index 1b037ba4af..09f9261bb1 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -191,6 +191,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const isFullScreen = useAtomValue(env.atoms.isFullScreen); const zoomFactor = useAtomValue(env.atoms.zoomFactorAtom); const settings = useAtomValue(env.atoms.settingsAtom); + const confirmClose = useAtomValue(env.getSettingsKeyAtom("tab:confirmclose")) ?? false; let prevDelta: number; let prevDragDirection: string; @@ -583,7 +584,6 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const handleCloseTab = (event: React.MouseEvent | null, tabId: string) => { event?.stopPropagation(); - const confirmClose = settings["tab:confirmclose"] ?? false; env.electron .closeTab(workspace.oid, tabId, confirmClose) .then((didClose) => { diff --git a/frontend/preview/previews/tabbar.preview.tsx b/frontend/preview/previews/tabbar.preview.tsx index de5b85f7cb..92469de1f9 100644 --- a/frontend/preview/previews/tabbar.preview.tsx +++ b/frontend/preview/previews/tabbar.preview.tsx @@ -23,6 +23,8 @@ type PreviewTabEntry = { const TabDefaultWidth = 130; const TabMinWidth = 100; const TabHeight = 26; +const MockWorkspaceSwitcherWidth = 42; +const MockAddTabButtonWidth = 44; const MockConfigErrors: ConfigError[] = [ { file: "~/.waveterm/config.json", err: "unknown preset \"bg@aurora\"" }, { file: "~/.waveterm/settings.json", err: "invalid color for tab theme" }, @@ -191,8 +193,8 @@ export function TabBarPreview() { windowDragRightWidth - (showAppMenuButton ? 28 : 0) - (hideAiButton ? 0 : 48) - - 42 - - 44 - + MockWorkspaceSwitcherWidth - + MockAddTabButtonWidth - (updaterStatus === "up-to-date" ? 0 : 164) - (showConfigErrors ? 132 : 0) - 24; @@ -346,10 +348,10 @@ export function TabBarPreview() { icon: "plus", title: "Add Tab", click: () => { - const tabId = `preview-tab-${crypto.randomUUID()}`; - const nextTab = { tabId, tabName: "New Tab" }; + const previewTabId = `preview-tab-${crypto.randomUUID()}`; + const nextTab = { tabId: previewTabId, tabName: "New Tab" }; setTabs((prevTabs) => [...prevTabs, nextTab]); - setActiveTabId(tabId); + setActiveTabId(previewTabId); }, }} /> From ab95a25972d1997114f1bae56379dc6c34a48138 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 10 Mar 2026 17:15:53 -0700 Subject: [PATCH 04/30] remove objectservice.settabname with an rpc call. use waveenv to replace deps in tab.tsx --- .kilocode/skills/add-rpc/SKILL.md | 4 +- frontend/app/store/services.ts | 5 --- frontend/app/store/wshclientapi.ts | 6 +++ frontend/app/tab/tab.tsx | 47 +++++++++++++++------- frontend/app/view/term/osc-handlers.ts | 6 +-- pkg/service/objectservice/objectservice.go | 17 -------- pkg/wshrpc/wshclient/wshclient.go | 6 +++ pkg/wshrpc/wshrpctypes.go | 1 + pkg/wshrpc/wshserver/wshserver.go | 10 +++++ 9 files changed, 60 insertions(+), 42 deletions(-) diff --git a/.kilocode/skills/add-rpc/SKILL.md b/.kilocode/skills/add-rpc/SKILL.md index 8bed6ea6e4..0bf5117f9f 100644 --- a/.kilocode/skills/add-rpc/SKILL.md +++ b/.kilocode/skills/add-rpc/SKILL.md @@ -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]` diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index f261f7e37b..2f18350962 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -83,11 +83,6 @@ class ObjectServiceType { UpdateObjectMeta(oref: string, meta: MetaType): Promise { return WOS.callBackendService("object", "UpdateObjectMeta", Array.from(arguments)) } - - // @returns object updates - UpdateTabName(tabId: string, name: string): Promise { - return WOS.callBackendService("object", "UpdateTabName", Array.from(arguments)) - } } export const ObjectService = new ObjectServiceType(); diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 377396c5a0..99ddbfcfbc 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -930,6 +930,12 @@ 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 { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "updatetabname", { args: [arg1, arg2] }, opts); + return client.wshRpcCall("updatetabname", { args: [arg1, arg2] }, opts); + } + // command "vdomasyncinitiation" [call] VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "vdomasyncinitiation", data, opts); diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 01a13bf13e..abf91326b7 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -2,9 +2,9 @@ // 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"; @@ -13,10 +13,21 @@ 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"]; +}>; + interface TabVProps { tabId: string; tabName: string; @@ -253,7 +264,8 @@ const FlagColors: { label: string; value: string }[] = [ function buildTabContextMenu( id: string, renameRef: React.RefObject<(() => void) | null>, - onClose: (event: React.MouseEvent | null) => void + onClose: (event: React.MouseEvent | null) => void, + env: TabEnv ): ContextMenuItem[] { const menu: ContextMenuItem[] = []; menu.push( @@ -271,17 +283,21 @@ 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 +319,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,7 +346,8 @@ interface TabProps { const TabInner = forwardRef((props, ref) => { const { id, active, showDivider, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart } = props; - const [tabData, _] = useWaveObjectValue(makeORef("tab", id)); + const env = useWaveEnv(); + const [tabData, _] = env.wos.useWaveObjectValue(makeORef("tab", id)); const badges = useAtomValue(getTabBadgeAtom(id)); const rawFlagColor = tabData?.meta?.["tab:flagcolor"]; @@ -361,18 +378,18 @@ const TabInner = forwardRef((props, ref) => { const handleContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault(); - const menu = buildTabContextMenu(id, renameRef, onClose); + const menu = buildTabContextMenu(id, renameRef, onClose, env); ContextMenuModel.getInstance().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 ( diff --git a/frontend/app/view/term/osc-handlers.ts b/frontend/app/view/term/osc-handlers.ts index 25fdf0e89b..f44659d2c6 100644 --- a/frontend/app/view/term/osc-handlers.ts +++ b/frontend/app/view/term/osc-handlers.ts @@ -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 }; diff --git a/pkg/service/objectservice/objectservice.go b/pkg/service/objectservice/objectservice.go index 8d6dc15690..0eb228181a 100644 --- a/pkg/service/objectservice/objectservice.go +++ b/pkg/service/objectservice/objectservice.go @@ -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"}, diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index c44c9c6abc..8640c32fdc 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -921,6 +921,12 @@ 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 "vdomasyncinitiation", wshserver.VDomAsyncInitiationCommand func VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiationRequest, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "vdomasyncinitiation", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 2c69ee0034..dc0aa30833 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -94,6 +94,7 @@ 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 GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error) // connection functions diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index e477914fa2..dc63e2e69a 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -160,6 +160,16 @@ 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) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error { log.Printf("SetMetaCommand: %s | %v\n", data.ORef, data.Meta) oref := data.ORef From 70a2da3d20abe0c879797d42f5c52e4655680141 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 10 Mar 2026 17:44:50 -0700 Subject: [PATCH 05/30] improve default mocking for waveobjs --- frontend/app/waveenv/waveenv.ts | 3 + frontend/app/waveenv/waveenvimpl.ts | 3 + frontend/preview/mock/mockwaveenv.ts | 101 ++++++++++++++++++++++----- 3 files changed, 88 insertions(+), 19 deletions(-) diff --git a/frontend/app/waveenv/waveenv.ts b/frontend/app/waveenv/waveenv.ts index 8a75072d79..0802677417 100644 --- a/frontend/app/waveenv/waveenv.ts +++ b/frontend/app/waveenv/waveenv.ts @@ -65,6 +65,9 @@ export type WaveEnv = { getSettingsKeyAtom: SettingsKeyAtomFnType; getBlockMetaKeyAtom: BlockMetaKeyAtomFnType; getConnConfigKeyAtom: ConnConfigKeyAtomFnType; + + // the mock fields are only usable in the preview server (may be be null or throw errors in production) + mockSetWaveObj: (oref: string, obj: WaveObj) => void; mockTabModel?: TabModel; }; diff --git a/frontend/app/waveenv/waveenvimpl.ts b/frontend/app/waveenv/waveenvimpl.ts index 1d78172d04..01dba54def 100644 --- a/frontend/app/waveenv/waveenvimpl.ts +++ b/frontend/app/waveenv/waveenvimpl.ts @@ -41,5 +41,8 @@ export function makeWaveEnvImpl(): WaveEnv { }, getBlockMetaKeyAtom, getConnConfigKeyAtom, + mockSetWaveObj: (_oref: string, _obj: WaveObj) => { + throw new Error("mockSetWaveObj is only available in the preview server"); + }, }; } diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index fdcfb02ba3..178248145d 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { makeDefaultConnStatus } from "@/app/store/global"; +import { globalStore } from "@/app/store/jotaiStore"; import { TabModel } from "@/app/store/tab-model"; +import { handleWaveEvent } from "@/app/store/wps"; import { RpcApiType } from "@/app/store/wshclientapi"; import { WaveEnv } from "@/app/waveenv/waveenv"; import { PlatformMacOS, PlatformWindows } from "@/util/platformutil"; @@ -10,6 +12,18 @@ import { Atom, atom, PrimitiveAtom } 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 +// +// 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"). + type RpcOverrides = { [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (...args: any[]) => any; }; @@ -113,8 +127,47 @@ function makeMockGlobalAtoms( return { ...defaults, ...atomOverrides }; } -export function makeMockRpc(overrides?: RpcOverrides): RpcApiType { +type MockWosFns = { + getWaveObjectAtom: (oref: string) => PrimitiveAtom; + mockSetWaveObj: (oref: string, obj: WaveObj) => void; +}; + +export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiType { const dispatchMap = new Map any>(); + dispatchMap.set("eventpublish", (_client, data: WaveEvent) => { + console.log("[mock eventpublish]", data); + handleWaveEvent(data); + return Promise.resolve(null); + }); + dispatchMap.set("getmeta", (_client, data: CommandGetMetaData) => { + const objAtom = wos.getWaveObjectAtom(data.oref); + const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType }; + return Promise.resolve(current?.meta ?? {}); + }); + dispatchMap.set("setmeta", (_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 Promise.resolve(null); + }); + dispatchMap.set("updatetabname", (_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 Promise.resolve(null); + }); if (overrides) { for (const key of Object.keys(overrides) as (keyof RpcOverrides)[]) { const cmdName = key.slice(0, -"Command".length).toLowerCase(); @@ -154,7 +207,8 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { const overrides: MockEnv = mockEnv ?? {}; const platform = overrides.platform ?? PlatformMacOS; const connStatusAtomCache = new Map>(); - const waveObjectAtomCache = new Map>(); + const waveObjectValueAtomCache = new Map>(); + const waveObjectDerivedAtomCache = new Map>(); const blockMetaKeyAtomCache = new Map>(); const connConfigKeyAtomCache = new Map>(); const atoms = makeMockGlobalAtoms(overrides.settings, overrides.atoms, overrides.tabId); @@ -165,6 +219,21 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { } return "user@localhost"; }); + const mockWosFns: MockWosFns = { + getWaveObjectAtom: (oref: string) => { + if (!waveObjectValueAtomCache.has(oref)) { + const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T; + waveObjectValueAtomCache.set(oref, atom(obj) as PrimitiveAtom); + } + return waveObjectValueAtomCache.get(oref) as PrimitiveAtom; + }, + mockSetWaveObj: (oref: string, obj: WaveObj) => { + if (!waveObjectValueAtomCache.has(oref)) { + waveObjectValueAtomCache.set(oref, atom(null as WaveObj)); + } + globalStore.set(waveObjectValueAtomCache.get(oref), obj); + }, + }; const env = { mockEnv: overrides, electron: { @@ -172,7 +241,7 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { getPlatform: () => platform, ...overrides.electron, }, - rpc: makeMockRpc(overrides.rpc), + rpc: makeMockRpc(overrides.rpc, mockWosFns), atoms, getSettingsKeyAtom: makeMockSettingsKeyAtom(atoms.settingsAtom, overrides.settings), platform, @@ -201,36 +270,30 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { return connStatusAtomCache.get(conn); }, wos: { - getWaveObjectAtom: (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; - }, + 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; + return waveObjectDerivedAtomCache.get(cacheKey) as Atom; }, 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; + return waveObjectDerivedAtomCache.get(cacheKey) as Atom; }, useWaveObjectValue: (oref: string): [T, boolean] => { - const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T; - return [obj, false]; + const objAtom = env.wos.getWaveObjectAtom(oref); + return [globalStore.get(objAtom), false]; }, }, + mockSetWaveObj: mockWosFns.mockSetWaveObj, getBlockMetaKeyAtom: (blockId: string, key: T) => { const cacheKey = blockId + "#meta-" + key; if (!blockMetaKeyAtomCache.has(cacheKey)) { From 41f419a07ab627ec7a17d910900f54a090d11ecc Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 10 Mar 2026 17:54:05 -0700 Subject: [PATCH 06/30] fix useWaveObjectValue and also mock contextmenu --- frontend/app/tab/tab.tsx | 8 +++++--- frontend/preview/mock/mockwaveenv.ts | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index abf91326b7..cfe0cf4283 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -6,7 +6,6 @@ import { getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/ap 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"; @@ -26,6 +25,7 @@ type TabEnv = WaveEnvSubset<{ fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; }; wos: WaveEnv["wos"]; + showContextMenu: WaveEnv["showContextMenu"]; }>; interface TabVProps { @@ -284,7 +284,9 @@ function buildTabContextMenu( type: "checkbox", checked: currentFlagColor == null, click: () => - fireAndForget(() => env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": null } })), + fireAndForget(() => + env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": null } }) + ), }, ...FlagColors.map((fc) => ({ label: fc.label, @@ -379,7 +381,7 @@ const TabInner = forwardRef((props, ref) => { (e: React.MouseEvent) => { e.preventDefault(); const menu = buildTabContextMenu(id, renameRef, onClose, env); - ContextMenuModel.getInstance().showContextMenu(menu, e); + env.showContextMenu(menu, e); }, [id, onClose, env] ); diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 178248145d..90193b5ced 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -8,7 +8,7 @@ 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"; @@ -290,7 +290,7 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { }, useWaveObjectValue: (oref: string): [T, boolean] => { const objAtom = env.wos.getWaveObjectAtom(oref); - return [globalStore.get(objAtom), false]; + return [useAtomValue(objAtom), false]; }, }, mockSetWaveObj: mockWosFns.mockSetWaveObj, From 90f4e22a0fa02698d89d587b4708d3a611e80f05 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 10 Mar 2026 18:17:10 -0700 Subject: [PATCH 07/30] mock Tab component directly (instead of TabV), and get badge mocking working --- frontend/app/store/badge.ts | 63 ++++++++---- frontend/app/tab/tab.tsx | 2 +- frontend/preview/previews/tabbar.preview.tsx | 100 +++++++++++++++---- package-lock.json | 4 +- tsconfig.json | 3 +- 5 files changed, 128 insertions(+), 44 deletions(-) diff --git a/frontend/app/store/badge.ts b/frontend/app/store/badge.ts index e3edb82103..e1cf8e5fe4 100644 --- a/frontend/app/store/badge.ts +++ b/frontend/app/store/badge.ts @@ -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>(); const TabBadgeAtomCache = new Map>(); -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(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 { return getBadgeAtom(oref); } -function getTabBadgeAtom(tabId: string): Atom { +function getTabBadgeAtom(tabId: string, env?: TabBadgesEnv): Atom { if (tabId == null) { return NullAtom as Atom; } @@ -98,7 +123,8 @@ function getTabBadgeAtom(tabId: string): Atom { } const tabOref = WOS.makeORef("tab", tabId); const tabBadgeAtom = getBadgeAtom(tabOref); - const tabAtom = atom((get) => WOS.getObjectValue(tabOref, get)); + const tabAtom = + env != null ? env.wos.getWaveObjectAtom(tabOref) : WOS.getWaveObjectAtom(tabOref); rtn = atom((get) => { const tab = get(tabAtom); const blockIds = tab?.blockids ?? []; @@ -119,8 +145,9 @@ function getTabBadgeAtom(tabId: string): Atom { 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 & { badgeid?: string }) { +function setBadge(blockId: string, badge: Omit & { 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 & { 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() { diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index cfe0cf4283..c4398e46b1 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -350,7 +350,7 @@ const TabInner = forwardRef((props, ref) => { const { id, active, showDivider, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart } = props; const env = useWaveEnv(); const [tabData, _] = env.wos.useWaveObjectValue(makeORef("tab", id)); - const badges = useAtomValue(getTabBadgeAtom(id)); + const badges = useAtomValue(getTabBadgeAtom(id, env)); const rawFlagColor = tabData?.meta?.["tab:flagcolor"]; let flagColor: string | null = null; diff --git a/frontend/preview/previews/tabbar.preview.tsx b/frontend/preview/previews/tabbar.preview.tsx index 92469de1f9..23068707a2 100644 --- a/frontend/preview/previews/tabbar.preview.tsx +++ b/frontend/preview/previews/tabbar.preview.tsx @@ -2,14 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 import WorkspaceSVG from "@/app/asset/workspace.svg"; -import { Tooltip } from "@/app/element/tooltip"; import { IconButton } from "@/app/element/iconbutton"; +import { Tooltip } from "@/app/element/tooltip"; +import { loadBadges, LoadBadgesEnv } from "@/app/store/badge"; import { getAtoms } from "@/app/store/global-atoms"; -import { TabV } from "@/app/tab/tab"; +import { Tab } from "@/app/tab/tab"; import { ConfigErrorIcon, WaveAIButton } from "@/app/tab/tabbar"; import { TabBarEnv } from "@/app/tab/tabbarenv"; import { UpdateStatusBanner } from "@/app/tab/updatebanner"; -import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; +import { applyMockEnvOverrides } from "@/preview/mock/mockwaveenv"; import { useAtom } from "jotai"; import { CSSProperties, useEffect, useMemo, useRef, useState } from "react"; @@ -20,13 +22,39 @@ type PreviewTabEntry = { 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 TabDefaultWidth = 130; const TabMinWidth = 100; const TabHeight = 26; const MockWorkspaceSwitcherWidth = 42; const MockAddTabButtonWidth = 44; const MockConfigErrors: ConfigError[] = [ - { file: "~/.waveterm/config.json", err: "unknown preset \"bg@aurora\"" }, + { file: "~/.waveterm/config.json", err: 'unknown preset "bg@aurora"' }, { file: "~/.waveterm/settings.json", err: "invalid color for tab theme" }, ]; const InitialTabs: PreviewTabEntry[] = [ @@ -34,12 +62,21 @@ const InitialTabs: PreviewTabEntry[] = [ { tabId: "preview-tab-2", tabName: "Build Logs", - badges: [{ badgeid: "01958000-0000-7000-0000-000000000001", icon: "triangle-exclamation", color: "#f59e0b", priority: 2 }], + 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 }], + badges: [ + { badgeid: "01958000-0000-7000-0000-000000000002", icon: "circle-check", color: "#4ade80", priority: 3 }, + ], flagColor: "#429dff", }, { @@ -74,7 +111,13 @@ function getWindowDragWidths(platform: NodeJS.Platform, isFullScreen: boolean, z function MockWorkspaceSwitcher({ divRef }: { divRef: React.RefObject }) { return ( - +
void; onCloseTab: (tabId: string) => void; - onRenameTab: (tabId: string, newName: string) => void; }) { const tabRefs = useRef>({}); const tabWidth = useMemo(() => { @@ -128,25 +169,21 @@ function MockTabStrip({ const isActive = tab.tabId === activeTabId; const showDivider = index !== 0 && !isActive && index !== activeIndex + 1; return ( - { tabRefs.current[tab.tabId] = el; }} - tabId={tab.tabId} - tabName={tab.tabName} + id={tab.tabId} active={isActive} showDivider={showDivider} isDragging={false} tabWidth={tabWidth} isNew={false} - badges={tab.badges ?? null} - flagColor={tab.flagColor ?? null} - onClick={() => onSelectTab(tab.tabId)} + onSelect={() => onSelectTab(tab.tabId)} onClose={() => onCloseTab(tab.tabId)} onDragStart={() => {}} - onContextMenu={() => {}} - onRename={(newName) => onRenameTab(tab.tabId, newName)} + onLoaded={() => {}} /> ); })} @@ -156,7 +193,26 @@ function MockTabStrip({ } export function TabBarPreview() { + const baseEnv = useWaveEnv(); + const tabEnv = useMemo(() => { + const mockWaveObjs = Object.fromEntries(InitialTabs.map((tab) => [`tab:${tab.tabId}`, makeTabWaveObj(tab)])); + return applyMockEnvOverrides(baseEnv, { + mockWaveObjs, + rpc: { + GetAllBadgesCommand: () => Promise.resolve(makeMockBadgeEvents()), + }, + }); + }, []); + return ( + + + + ); +} + +function TabBarPreviewInner() { const env = useWaveEnv(); + const loadBadgesEnv = useWaveEnv(); const [tabs, setTabs] = useState(InitialTabs); const [activeTabId, setActiveTabId] = useState(InitialTabs[1].tabId); const [frameWidth, setFrameWidth] = useState(1180); @@ -173,6 +229,10 @@ export function TabBarPreview() { const updateStatusBannerRef = useRef(null); const configErrorButtonRef = useRef(null); + useEffect(() => { + loadBadges(loadBadgesEnv); + }, []); + useEffect(() => { setFullConfig((prev) => ({ ...(prev ?? ({} as FullConfigType)), @@ -334,11 +394,6 @@ export function TabBarPreview() { return nextTabs; }); }} - onRenameTab={(tabId, newName) => { - setTabs((prevTabs) => - prevTabs.map((tab) => (tab.tabId === tabId ? { ...tab, tabName: newName } : tab)) - ); - }} />
); } +TabBarPreviewInner.displayName = "TabBarPreviewInner"; diff --git a/package-lock.json b/package-lock.json index fd6edbe6c5..99c2a025b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.2-beta.0", + "version": "0.14.2-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.2-beta.0", + "version": "0.14.2-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/tsconfig.json b/tsconfig.json index cb02488e63..3ef02e0671 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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, From 9e741320ba55792044d880731a3d8f5f97a164db Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 10 Mar 2026 19:08:02 -0700 Subject: [PATCH 08/30] move UpdateTabIds to rpc, and mock --- frontend/app/store/services.ts | 5 ----- frontend/app/store/wshclientapi.ts | 6 ++++++ frontend/app/tab/tabbar.tsx | 6 +++--- frontend/app/tab/tabbarenv.ts | 3 +++ frontend/preview/mock/mockwaveenv.ts | 20 ++++++++++++++----- .../workspaceservice/workspaceservice.go | 19 ------------------ pkg/wshrpc/wshclient/wshclient.go | 6 ++++++ pkg/wshrpc/wshrpctypes.go | 1 + pkg/wshrpc/wshserver/wshserver.go | 10 ++++++++++ 9 files changed, 44 insertions(+), 32 deletions(-) diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 2f18350962..6b69d0a52e 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -165,11 +165,6 @@ class WorkspaceServiceType { return WOS.callBackendService("workspace", "SetActiveTab", Array.from(arguments)) } - // @returns object updates - UpdateTabIds(workspaceId: string, tabIds: string[]): Promise { - return WOS.callBackendService("workspace", "UpdateTabIds", Array.from(arguments)) - } - // @returns object updates UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, applyDefaults: boolean): Promise { return WOS.callBackendService("workspace", "UpdateWorkspace", Array.from(arguments)) diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 99ddbfcfbc..6b9f4a72d4 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -936,6 +936,12 @@ export class RpcApiType { return client.wshRpcCall("updatetabname", { args: [arg1, arg2] }, opts); } + // command "updateworkspacetabids" [call] + UpdateWorkspaceTabIdsCommand(client: WshClient, arg1: string, arg2: string[], opts?: RpcOpts): Promise { + 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 { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "vdomasyncinitiation", data, opts); diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index 09f9261bb1..7b06c671f8 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -4,8 +4,8 @@ import { Button } from "@/app/element/button"; import { Tooltip } from "@/app/element/tooltip"; import { modalsModel } from "@/app/store/modalmodel"; -import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { deleteLayoutModelForTab } from "@/layout/index"; import { fireAndForget } from "@/util/util"; import { useAtomValue } from "jotai"; @@ -13,7 +13,7 @@ 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 { TabRpcClient } from "@/app/store/wshrpcutil"; import { Tab } from "./tab"; import "./tabbar.scss"; import { TabBarEnv } from "./tabbarenv"; @@ -488,7 +488,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)); }), [] ); diff --git a/frontend/app/tab/tabbarenv.ts b/frontend/app/tab/tabbarenv.ts index a08a0ee465..f92d12ed9f 100644 --- a/frontend/app/tab/tabbarenv.ts +++ b/frontend/app/tab/tabbarenv.ts @@ -10,6 +10,9 @@ export type TabBarEnv = WaveEnvSubset<{ setActiveTab: WaveEnv["electron"]["setActiveTab"]; showWorkspaceAppMenu: WaveEnv["electron"]["showWorkspaceAppMenu"]; }; + rpc: { + UpdateWorkspaceTabIdsCommand: WaveEnv["rpc"]["UpdateWorkspaceTabIdsCommand"]; + }; atoms: { fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; staticTabId: WaveEnv["atoms"]["staticTabId"]; diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 90193b5ced..c350ab3fd1 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -15,11 +15,12 @@ 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.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"). @@ -168,6 +169,15 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp wos.mockSetWaveObj(tabORef, updated); return Promise.resolve(null); }); + dispatchMap.set("updateworkspacetabids", (_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 Promise.resolve(null); + }); if (overrides) { for (const key of Object.keys(overrides) as (keyof RpcOverrides)[]) { const cmdName = key.slice(0, -"Command".length).toLowerCase(); diff --git a/pkg/service/workspaceservice/workspaceservice.go b/pkg/service/workspaceservice/workspaceservice.go index c0d5072a48..1d7b116bdc 100644 --- a/pkg/service/workspaceservice/workspaceservice.go +++ b/pkg/service/workspaceservice/workspaceservice.go @@ -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"}, diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 8640c32fdc..110e1695ef 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -927,6 +927,12 @@ func UpdateTabNameCommand(w *wshutil.WshRpc, arg1 string, arg2 string, opts *wsh 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) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index dc0aa30833..8ddff8128b 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -95,6 +95,7 @@ type WshRpcInterface interface { 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 diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index dc63e2e69a..670c949f2e 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -170,6 +170,16 @@ func (ws *WshServer) UpdateTabNameCommand(ctx context.Context, tabId string, new 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 From 86592209d740477c5c4ddf77823f2d7d4f06f0de Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 10 Mar 2026 21:17:02 -0700 Subject: [PATCH 09/30] generic set --- frontend/app/store/wps.ts | 4 ++-- frontend/app/tab/tabbarenv.ts | 2 ++ frontend/app/waveenv/waveenv.ts | 2 +- frontend/app/waveenv/waveenvimpl.ts | 2 +- frontend/preview/mock/mockwaveenv.ts | 4 ++-- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/app/store/wps.ts b/frontend/app/store/wps.ts index 745734123c..bda612fc70 100644 --- a/frontend/app/store/wps.ts +++ b/frontend/app/store/wps.ts @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { WshClient } from "@/app/store/wshclient"; @@ -84,7 +84,7 @@ function waveEventSubscribeSingle(subscription: WaveEve function waveEventUnsubscribe(...unsubscribes: WaveEventUnsubscribe[]) { const eventTypeSet = new Set(); for (const unsubscribe of unsubscribes) { - let subjects = waveEventSubjects.get(unsubscribe.eventType); + const subjects = waveEventSubjects.get(unsubscribe.eventType); if (subjects == null) { return; } diff --git a/frontend/app/tab/tabbarenv.ts b/frontend/app/tab/tabbarenv.ts index f92d12ed9f..0ff9c97234 100644 --- a/frontend/app/tab/tabbarenv.ts +++ b/frontend/app/tab/tabbarenv.ts @@ -21,7 +21,9 @@ export type TabBarEnv = WaveEnvSubset<{ settingsAtom: WaveEnv["atoms"]["settingsAtom"]; reinitVersion: WaveEnv["atoms"]["reinitVersion"]; }; + wos: WaveEnv["wos"]; getSettingsKeyAtom: SettingsKeyAtomFnType<"app:hideaibutton" | "tab:confirmclose">; + mockSetWaveObj: WaveEnv["mockSetWaveObj"]; isWindows: WaveEnv["isWindows"]; isMacOS: WaveEnv["isMacOS"]; }>; diff --git a/frontend/app/waveenv/waveenv.ts b/frontend/app/waveenv/waveenv.ts index 0802677417..695df1e7c6 100644 --- a/frontend/app/waveenv/waveenv.ts +++ b/frontend/app/waveenv/waveenv.ts @@ -67,7 +67,7 @@ export type WaveEnv = { getConnConfigKeyAtom: ConnConfigKeyAtomFnType; // the mock fields are only usable in the preview server (may be be null or throw errors in production) - mockSetWaveObj: (oref: string, obj: WaveObj) => void; + mockSetWaveObj: (oref: string, obj: T) => void; mockTabModel?: TabModel; }; diff --git a/frontend/app/waveenv/waveenvimpl.ts b/frontend/app/waveenv/waveenvimpl.ts index 01dba54def..1389af27e8 100644 --- a/frontend/app/waveenv/waveenvimpl.ts +++ b/frontend/app/waveenv/waveenvimpl.ts @@ -41,7 +41,7 @@ export function makeWaveEnvImpl(): WaveEnv { }, getBlockMetaKeyAtom, getConnConfigKeyAtom, - mockSetWaveObj: (_oref: string, _obj: WaveObj) => { + mockSetWaveObj: (_oref: string, _obj: T) => { throw new Error("mockSetWaveObj is only available in the preview server"); }, }; diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index c350ab3fd1..5f1b5e3dc5 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -130,7 +130,7 @@ function makeMockGlobalAtoms( type MockWosFns = { getWaveObjectAtom: (oref: string) => PrimitiveAtom; - mockSetWaveObj: (oref: string, obj: WaveObj) => void; + mockSetWaveObj: (oref: string, obj: T) => void; }; export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiType { @@ -237,7 +237,7 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { } return waveObjectValueAtomCache.get(oref) as PrimitiveAtom; }, - mockSetWaveObj: (oref: string, obj: WaveObj) => { + mockSetWaveObj: (oref: string, obj: T) => { if (!waveObjectValueAtomCache.has(oref)) { waveObjectValueAtomCache.set(oref, atom(null as WaveObj)); } From 24a4d7c64d62b58d7cd4eb4626ce29ef8b91192d Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 10 Mar 2026 21:49:35 -0700 Subject: [PATCH 10/30] workspaceid atom --- frontend/app/store/contextmenu.ts | 6 +-- frontend/app/store/global-atoms.ts | 11 +++-- frontend/app/store/keymodel.ts | 4 +- frontend/app/workspace/widgets.tsx | 6 +-- frontend/preview/mock/mockwaveenv.ts | 41 +++++++++++++------ frontend/preview/preview.tsx | 2 + frontend/preview/previews/widgets.preview.tsx | 2 - frontend/types/custom.d.ts | 3 +- 8 files changed, 48 insertions(+), 27 deletions(-) diff --git a/frontend/app/store/contextmenu.ts b/frontend/app/store/contextmenu.ts index 89e72c5613..fdad72bd88 100644 --- a/frontend/app/store/contextmenu.ts +++ b/frontend/app/store/contextmenu.ts @@ -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); } diff --git a/frontend/app/store/global-atoms.ts b/frontend/app/store/global-atoms.ts index 6d24666ff0..a4b1cfd4aa 100644 --- a/frontend/app/store/global-atoms.ts +++ b/frontend/app/store/global-atoms.ts @@ -43,12 +43,16 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { console.log("failed to initialize zoomFactorAtom", e); } - const workspaceAtom: Atom = atom((get) => { + const workspaceIdAtom: Atom = atom((get) => { const windowData = WOS.getObjectValue(WOS.makeORef("window", get(windowIdAtom)), get); - if (windowData == null) { + return windowData?.workspaceid ?? null; + }); + const workspaceAtom: Atom = 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; const waveaiModeConfigAtom = atom(null) as PrimitiveAtom>; @@ -123,6 +127,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { builderId: builderIdAtom, builderAppId: builderAppIdAtom, uiContext: uiContextAtom, + workspaceId: workspaceIdAtom, workspace: workspaceAtom, fullConfigAtom, waveaiModeConfigAtom, diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 97095d4b04..251dc05176 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -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); diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index 2d73119154..2ec171953e 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -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(); 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(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); diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 5f1b5e3dc5..062d72830d 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -88,9 +88,10 @@ function makeMockSettingsKeyAtom( } function makeMockGlobalAtoms( - settingsOverrides?: Partial, - atomOverrides?: Partial, - tabId?: string + settingsOverrides: Partial, + atomOverrides: Partial, + tabId: string, + getWaveObjectAtom: (oref: string) => PrimitiveAtom ): GlobalAtomsType { let fullConfig = DefaultFullConfig; if (settingsOverrides) { @@ -101,11 +102,20 @@ function makeMockGlobalAtoms( } const fullConfigAtom = atom(fullConfig) as PrimitiveAtom; const settingsAtom = atom((get) => get(fullConfigAtom)?.settings ?? {}) as Atom; + const workspaceIdAtom: Atom = atomOverrides?.workspaceId ?? (atom(null as string) as Atom); + const workspaceAtom: Atom = atom((get) => { + const wsId = get(workspaceIdAtom); + if (wsId == null) { + return null; + } + return get(getWaveObjectAtom("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, @@ -125,7 +135,11 @@ function makeMockGlobalAtoms( if (!atomOverrides) { return defaults; } - return { ...defaults, ...atomOverrides }; + const merged = { ...defaults, ...atomOverrides }; + if (!atomOverrides.workspace) { + merged.workspace = workspaceAtom; + } + return merged; } type MockWosFns = { @@ -221,7 +235,14 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { const waveObjectDerivedAtomCache = new Map>(); const blockMetaKeyAtomCache = new Map>(); const connConfigKeyAtomCache = new Map>(); - const atoms = makeMockGlobalAtoms(overrides.settings, overrides.atoms, overrides.tabId); + const getWaveObjectAtom = (oref: string): PrimitiveAtom => { + if (!waveObjectValueAtomCache.has(oref)) { + const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T; + waveObjectValueAtomCache.set(oref, atom(obj) as PrimitiveAtom); + } + return waveObjectValueAtomCache.get(oref) as PrimitiveAtom; + }; + const atoms = makeMockGlobalAtoms(overrides.settings, overrides.atoms, overrides.tabId, getWaveObjectAtom); const localHostDisplayNameAtom = atom((get) => { const configValue = get(atoms.settingsAtom)?.["conn:localhostdisplayname"]; if (configValue != null) { @@ -230,13 +251,7 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { return "user@localhost"; }); const mockWosFns: MockWosFns = { - getWaveObjectAtom: (oref: string) => { - if (!waveObjectValueAtomCache.has(oref)) { - const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T; - waveObjectValueAtomCache.set(oref, atom(obj) as PrimitiveAtom); - } - return waveObjectValueAtomCache.get(oref) as PrimitiveAtom; - }, + getWaveObjectAtom, mockSetWaveObj: (oref: string, obj: T) => { if (!waveObjectValueAtomCache.has(oref)) { waveObjectValueAtomCache.set(oref, atom(null as WaveObj)); diff --git a/frontend/preview/preview.tsx b/frontend/preview/preview.tsx index 9cb03c0014..d749f82655 100644 --- a/frontend/preview/preview.tsx +++ b/frontend/preview/preview.tsx @@ -95,6 +95,7 @@ function PreviewRoot() { atoms: { uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext), staticTabId: atom(PreviewTabId), + workspaceId: atom(PreviewWorkspaceId), }, }) ); @@ -143,6 +144,7 @@ function PreviewApp() { const PreviewTabId = crypto.randomUUID(); const PreviewWindowId = crypto.randomUUID(); +const PreviewWorkspaceId = crypto.randomUUID(); const PreviewClientId = crypto.randomUUID(); function initPreview() { diff --git a/frontend/preview/previews/widgets.preview.tsx b/frontend/preview/previews/widgets.preview.tsx index b6970da902..144cace174 100644 --- a/frontend/preview/previews/widgets.preview.tsx +++ b/frontend/preview/previews/widgets.preview.tsx @@ -7,7 +7,6 @@ import { atom, useAtom } from "jotai"; import { useRef } from "react"; import { applyMockEnvOverrides } from "../mock/mockwaveenv"; -const workspaceAtom = atom(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), }, }); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 8ee176e151..e9ca76cf07 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -11,7 +11,8 @@ declare global { builderId: jotai.Atom; // readonly (for builder mode) builderAppId: jotai.PrimitiveAtom; // app being edited in builder mode uiContext: jotai.Atom; // driven from windowId, tabId - workspace: jotai.Atom; // driven from WOS + workspaceId: jotai.Atom; // derived from window WOS object + workspace: jotai.Atom; // driven from workspaceId via WOS fullConfigAtom: jotai.PrimitiveAtom; // driven from WOS, settings -- updated via WebSocket waveaiModeConfigAtom: jotai.PrimitiveAtom>; // resolved AI mode configs -- updated via WebSocket settingsAtom: jotai.Atom; // derrived from fullConfig From f87b8c1fe64b7d7a6748bc7d4ea08a976cd7ea87 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 10 Mar 2026 22:09:41 -0700 Subject: [PATCH 11/30] error boundary --- frontend/preview/preview.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/preview/preview.tsx b/frontend/preview/preview.tsx index d749f82655..89ae70cdd1 100644 --- a/frontend/preview/preview.tsx +++ b/frontend/preview/preview.tsx @@ -8,6 +8,7 @@ import { globalStore } from "@/app/store/jotaiStore"; import { WaveEnvContext } from "@/app/waveenv/waveenv"; import { loadFonts } from "@/util/fontutil"; import { atom, Provider } from "jotai"; +import { ErrorBoundary } from "@/app/element/errorboundary"; import React, { lazy, Suspense, useRef } from "react"; import { createRoot } from "react-dom/client"; import { makeMockWaveEnv } from "./mock/mockwaveenv"; @@ -119,9 +120,11 @@ function PreviewApp() { <>
- - - + + + + +
); From 13604923351c7b0df1a277cbec24148a8580fa1b Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 10 Mar 2026 22:31:49 -0700 Subject: [PATCH 12/30] better way to mock singleton or static keyed models --- .kilocode/skills/waveenv/SKILL.md | 8 ++++++ frontend/app/store/keymodel.ts | 4 +-- frontend/app/store/tab-model.ts | 38 +++++++++++++++------------- frontend/app/waveenv/waveenv.ts | 25 +++++++++++------- frontend/app/waveenv/waveenvimpl.ts | 3 +++ frontend/preview/mock/mockwaveenv.ts | 9 +++---- 6 files changed, 53 insertions(+), 34 deletions(-) diff --git a/.kilocode/skills/waveenv/SKILL.md b/.kilocode/skills/waveenv/SKILL.md index a78490f449..4f4c4e0dff 100644 --- a/.kilocode/skills/waveenv/SKILL.md +++ b/.kilocode/skills/waveenv/SKILL.md @@ -80,6 +80,14 @@ export type MyEnv = WaveEnvSubset<{ }>; ``` +### Automatically Included Fields + +Every `WaveEnvSubset` automatically includes the mock fields — you never need to declare them: + +- `isMock: boolean` +- `mockSetWaveObj: (oref: string, obj: T) => void` +- `mockModels?: Map` + ### Rules for Each Section | Section | Pattern | Notes | diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 251dc05176..afa5209116 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -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"; @@ -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)) { diff --git a/frontend/app/store/tab-model.ts b/frontend/app/store/tab-model.ts index 6c41e2fd84..a867440820 100644 --- a/frontend/app/store/tab-model.ts +++ b/frontend/app/store/tab-model.ts @@ -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(); export const activeTabIdAtom = atom(null) as PrimitiveAtom; export class TabModel { tabId: string; - waveEnv: WaveEnv; + waveEnv: TabModelEnv; tabAtom: Atom; tabNumBlocksAtom: Atom; isTermMultiInput = atom(false) as PrimitiveAtom; metaCache: Map> = 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(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); } diff --git a/frontend/app/waveenv/waveenv.ts b/frontend/app/waveenv/waveenv.ts index 695df1e7c6..53a09aeb7a 100644 --- a/frontend/app/waveenv/waveenv.ts +++ b/frontend/app/waveenv/waveenv.ts @@ -1,7 +1,6 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import type { TabModel } from "@/app/store/tab-model"; import { RpcApiType } from "@/app/store/wshclientapi"; import { Atom, PrimitiveAtom } from "jotai"; import React from "react"; @@ -35,16 +34,24 @@ type ComplexWaveEnvKeys = { wos: WaveEnv["wos"]; }; -export type WaveEnvSubset = OmitNever<{ - [K in keyof T]: K extends keyof ComplexWaveEnvKeys - ? Subset - : K extends keyof WaveEnv - ? T[K] - : never; -}>; +type WaveEnvMockFields = { + isMock: WaveEnv["isMock"]; + mockSetWaveObj: WaveEnv["mockSetWaveObj"]; + mockModels: WaveEnv["mockModels"]; +}; + +export type WaveEnvSubset = WaveEnvMockFields & + OmitNever<{ + [K in keyof T]: K extends keyof ComplexWaveEnvKeys + ? Subset + : 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; @@ -68,7 +75,7 @@ export type WaveEnv = { // the mock fields are only usable in the preview server (may be be null or throw errors in production) mockSetWaveObj: (oref: string, obj: T) => void; - mockTabModel?: TabModel; + mockModels?: Map; }; export const WaveEnvContext = React.createContext(null); diff --git a/frontend/app/waveenv/waveenvimpl.ts b/frontend/app/waveenv/waveenvimpl.ts index 1389af27e8..bd52173205 100644 --- a/frontend/app/waveenv/waveenvimpl.ts +++ b/frontend/app/waveenv/waveenvimpl.ts @@ -19,6 +19,7 @@ import { isMacOS, isWindows, PLATFORM } from "@/util/platformutil"; export function makeWaveEnvImpl(): WaveEnv { return { + isMock: false, electron: (window as any).api, rpc: RpcApi, getSettingsKeyAtom, @@ -41,8 +42,10 @@ export function makeWaveEnvImpl(): WaveEnv { }, getBlockMetaKeyAtom, getConnConfigKeyAtom, + mockSetWaveObj: (_oref: string, _obj: T) => { throw new Error("mockSetWaveObj is only available in the preview server"); }, + mockModels: new Map(), }; } diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 062d72830d..6020f7ab11 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -3,7 +3,6 @@ import { makeDefaultConnStatus } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; -import { TabModel } from "@/app/store/tab-model"; import { handleWaveEvent } from "@/app/store/wps"; import { RpcApiType } from "@/app/store/wshclientapi"; import { WaveEnv } from "@/app/waveenv/waveenv"; @@ -260,6 +259,7 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { }, }; const env = { + isMock: true, mockEnv: overrides, electron: { ...previewElectronApi, @@ -318,7 +318,6 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { return [useAtomValue(objAtom), false]; }, }, - mockSetWaveObj: mockWosFns.mockSetWaveObj, getBlockMetaKeyAtom: (blockId: string, key: T) => { const cacheKey = blockId + "#meta-" + key; if (!blockMetaKeyAtomCache.has(cacheKey)) { @@ -343,10 +342,8 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { } return connConfigKeyAtomCache.get(cacheKey) as Atom; }, - mockTabModel: null as TabModel, + mockSetWaveObj: mockWosFns.mockSetWaveObj, + mockModels: new Map(), } as MockWaveEnv; - if (overrides.tabId != null) { - env.mockTabModel = new TabModel(overrides.tabId, env); - } return env; } From 099eb0fdd47da5e1643dd1070b4d00ccf76d2267 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 10 Mar 2026 22:35:45 -0700 Subject: [PATCH 13/30] env for workspaceswitcher --- frontend/app/tab/workspaceswitcher.tsx | 27 +++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx index 7a253b81e4..f8f74d9cfb 100644 --- a/frontend/app/tab/workspaceswitcher.tsx +++ b/frontend/app/tab/workspaceswitcher.tsx @@ -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,23 @@ 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 { 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"]; + }; +}>; + type WorkspaceListEntry = { windowId: string; workspace: Workspace; @@ -35,8 +46,9 @@ const workspaceMapAtom = atom([]); const workspaceSplitAtom = splitAtom(workspaceMapAtom); const editingWorkspaceAtom = atom(); const WorkspaceSwitcher = forwardRef((_, ref) => { + const env = useWaveEnv(); const setWorkspaceList = useSetAtom(workspaceMapAtom); - const activeWorkspace = useAtomValueSafe(atoms.workspace); + const activeWorkspace = useAtomValueSafe(env.atoms.workspace); const workspaceList = useAtomValue(workspaceSplitAtom); const setEditingWorkspace = useSetAtom(editingWorkspaceAtom); @@ -71,7 +83,7 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { }, []); const onDeleteWorkspace = useCallback((workspaceId: string) => { - getApi().deleteWorkspace(workspaceId); + env.electron.deleteWorkspace(workspaceId); }, []); const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon); @@ -118,7 +130,7 @@ const WorkspaceSwitcher = forwardRef((_, ref) => {
{isActiveWorkspaceSaved ? ( - getApi().createWorkspace()}> + env.electron.createWorkspace()}> @@ -145,7 +157,8 @@ const WorkspaceSwitcherItem = ({ entryAtom: PrimitiveAtom; onDeleteWorkspace: (workspaceId: string) => void; }) => { - const activeWorkspace = useAtomValueSafe(atoms.workspace); + const env = useWaveEnv(); + const activeWorkspace = useAtomValueSafe(env.atoms.workspace); const [workspaceEntry, setWorkspaceEntry] = useAtom(entryAtom); const [editingWorkspace, setEditingWorkspace] = useAtom(editingWorkspaceAtom); @@ -200,7 +213,7 @@ const WorkspaceSwitcherItem = ({ > { - 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" })); }} From 61eac7fbf04f488b6019be81a666957243ae544f Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 10 Mar 2026 23:01:04 -0700 Subject: [PATCH 14/30] update services for mocking --- cmd/generatets/main-generatets.go | 25 ++++- eslint.config.js | 1 + frontend/app/store/services.ts | 137 +++++++++++++++++++-------- frontend/app/waveenv/waveenv.ts | 1 + frontend/app/waveenv/waveenvimpl.ts | 1 + frontend/preview/mock/mockwaveenv.ts | 31 ++++++ pkg/tsgen/tsgen.go | 8 +- 7 files changed, 163 insertions(+), 41 deletions(-) diff --git a/cmd/generatets/main-generatets.go b/cmd/generatets/main-generatets.go index 495c7d47f9..613470d4a1 100644 --- a/cmd/generatets/main-generatets.go +++ b/cmd/generatets/main-generatets.go @@ -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 { WaveEnv } from \"@/app/waveenv/waveenv\";\n\n") + fmt.Fprintf(&buf, "function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise {\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) diff --git a/eslint.config.js b/eslint.config.js index 50fe7ef7c3..6e98b1d805 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -89,6 +89,7 @@ export default [ { files: ["frontend/app/store/services.ts"], rules: { + "@typescript-eslint/no-unused-vars": "off", "prefer-rest-params": "off", }, }, diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 6b69d0a52e..d3cece18e9 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -4,172 +4,233 @@ // generated by cmd/generate/main-generatets.go import * as WOS from "./wos"; +import { WaveEnv } from "@/app/waveenv/waveenv"; + +function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise { + 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 { - return WOS.callBackendService("block", "CleanupOrphanedBlocks", Array.from(arguments)) + return callBackendService(this.waveEnv, "block", "CleanupOrphanedBlocks", Array.from(arguments)) } GetControllerStatus(arg2: string): Promise { - 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 { - return WOS.callBackendService("block", "SaveTerminalState", Array.from(arguments)) + return callBackendService(this.waveEnv, "block", "SaveTerminalState", Array.from(arguments)) } SaveWaveAiData(arg2: string, arg3: WaveAIPromptMessageType[]): Promise { - 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 { - return WOS.callBackendService("client", "AgreeTos", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "AgreeTos", Array.from(arguments)) } FocusWindow(arg2: string): Promise { - return WOS.callBackendService("client", "FocusWindow", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "FocusWindow", Array.from(arguments)) } GetAllConnStatus(): Promise { - return WOS.callBackendService("client", "GetAllConnStatus", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "GetAllConnStatus", Array.from(arguments)) } GetClientData(): Promise { - return WOS.callBackendService("client", "GetClientData", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "GetClientData", Array.from(arguments)) } GetTab(arg1: string): Promise { - return WOS.callBackendService("client", "GetTab", Array.from(arguments)) + return callBackendService(this.waveEnv, "client", "GetTab", Array.from(arguments)) } TelemetryUpdate(arg2: boolean): Promise { - 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 { - return WOS.callBackendService("object", "CreateBlock", Array.from(arguments)) + return callBackendService(this.waveEnv, "object", "CreateBlock", Array.from(arguments)) } // @returns object updates DeleteBlock(blockId: string): Promise { - 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 { - return WOS.callBackendService("object", "GetObject", Array.from(arguments)) + return callBackendService(this.waveEnv, "object", "GetObject", Array.from(arguments)) } // @returns objects GetObjects(orefs: string[]): Promise { - 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 { - 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 { - return WOS.callBackendService("object", "UpdateObjectMeta", 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 { - 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 { - return WOS.callBackendService("window", "CloseWindow", Array.from(arguments)) + return callBackendService(this.waveEnv, "window", "CloseWindow", Array.from(arguments)) } CreateWindow(winSize: WinSize, workspaceId: string): Promise { - return WOS.callBackendService("window", "CreateWindow", Array.from(arguments)) + return callBackendService(this.waveEnv, "window", "CreateWindow", Array.from(arguments)) } GetWindow(windowId: string): Promise { - 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 { - return WOS.callBackendService("window", "SetWindowPosAndSize", Array.from(arguments)) + return callBackendService(this.waveEnv, "window", "SetWindowPosAndSize", Array.from(arguments)) } SwitchWorkspace(windowId: string, workspaceId: string): Promise { - 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 { - 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 { - 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 { - return WOS.callBackendService("workspace", "CreateWorkspace", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "CreateWorkspace", Array.from(arguments)) } // @returns object updates DeleteWorkspace(workspaceId: string): Promise { - return WOS.callBackendService("workspace", "DeleteWorkspace", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "DeleteWorkspace", Array.from(arguments)) } // @returns colors GetColors(): Promise { - return WOS.callBackendService("workspace", "GetColors", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "GetColors", Array.from(arguments)) } // @returns icons GetIcons(): Promise { - return WOS.callBackendService("workspace", "GetIcons", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "GetIcons", Array.from(arguments)) } // @returns workspace GetWorkspace(workspaceId: string): Promise { - return WOS.callBackendService("workspace", "GetWorkspace", Array.from(arguments)) + return callBackendService(this.waveEnv, "workspace", "GetWorkspace", Array.from(arguments)) } ListWorkspaces(): Promise { - 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 { - return WOS.callBackendService("workspace", "SetActiveTab", 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 { - 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, +}; diff --git a/frontend/app/waveenv/waveenv.ts b/frontend/app/waveenv/waveenv.ts index 53a09aeb7a..b57202e115 100644 --- a/frontend/app/waveenv/waveenv.ts +++ b/frontend/app/waveenv/waveenv.ts @@ -60,6 +60,7 @@ export type WaveEnv = { isMacOS: () => boolean; atoms: GlobalAtomsType; createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => Promise; + callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => Promise; showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => void; getConnStatusAtom: (conn: string) => PrimitiveAtom; getLocalHostDisplayNameAtom: () => Atom; diff --git a/frontend/app/waveenv/waveenvimpl.ts b/frontend/app/waveenv/waveenvimpl.ts index bd52173205..bef0bcbd38 100644 --- a/frontend/app/waveenv/waveenvimpl.ts +++ b/frontend/app/waveenv/waveenvimpl.ts @@ -29,6 +29,7 @@ export function makeWaveEnvImpl(): WaveEnv { isMacOS, atoms, createBlock, + callBackendService: WOS.callBackendService, showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => { ContextMenuModel.getInstance().showContextMenu(menu, e); }, diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 6020f7ab11..032968575a 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -23,17 +23,29 @@ import { previewElectronApi } from "./preview-electron-api"; // // 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; }; +type ServiceOverrides = { + [Service: string]: { + [Method: string]: (...args: any[]) => any; + }; +}; + export type MockEnv = { isDev?: boolean; tabId?: string; platform?: NodeJS.Platform; settings?: Partial; rpc?: RpcOverrides; + services?: ServiceOverrides; atoms?: Partial; electron?: Partial; createBlock?: WaveEnv["createBlock"]; @@ -52,12 +64,23 @@ function mergeRecords(base: Record, overrides: Record): } 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 @@ -342,6 +365,14 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { } return connConfigKeyAtomCache.get(cacheKey) as Atom; }, + callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => { + const fn = overrides.services?.[service]?.[method]; + if (fn) { + return Promise.resolve(fn(...args)); + } + console.log("[mock callBackendService]", service, method, args, noUIContext); + return Promise.resolve(null); + }, mockSetWaveObj: mockWosFns.mockSetWaveObj, mockModels: new Map(), } as MockWaveEnv; diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index f990019ecd..8d92893afc 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -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) From 7be80be1a8ac7d1f9b834d1941ebfec027af9489 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 10 Mar 2026 23:24:23 -0700 Subject: [PATCH 15/30] waveenv mock out services. update workspaceswitcher. stop subscription calls to the backend in wps when in preview mode... --- .kilocode/skills/waveenv/SKILL.md | 7 +++++++ cmd/generatets/main-generatets.go | 2 +- frontend/app/store/services.ts | 2 +- frontend/app/store/wps.ts | 4 ++++ frontend/app/tab/workspaceswitcher.tsx | 18 +++++++++++------- frontend/app/waveenv/waveenv.ts | 3 +++ frontend/app/waveenv/waveenvimpl.ts | 2 ++ frontend/preview/mock/mockwaveenv.ts | 5 +++++ 8 files changed, 34 insertions(+), 9 deletions(-) diff --git a/.kilocode/skills/waveenv/SKILL.md b/.kilocode/skills/waveenv/SKILL.md index 4f4c4e0dff..aabda6846d 100644 --- a/.kilocode/skills/waveenv/SKILL.md +++ b/.kilocode/skills/waveenv/SKILL.md @@ -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">; @@ -96,6 +102,7 @@ Every `WaveEnvSubset` automatically includes the mock fields — you never ne | `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. | diff --git a/cmd/generatets/main-generatets.go b/cmd/generatets/main-generatets.go index 613470d4a1..f282f9fa19 100644 --- a/cmd/generatets/main-generatets.go +++ b/cmd/generatets/main-generatets.go @@ -89,7 +89,7 @@ func generateServicesFile(tsTypesMap map[reflect.Type]string) error { 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") - fmt.Fprintf(&buf, "import { WaveEnv } from \"@/app/waveenv/waveenv\";\n\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 {\n") fmt.Fprintf(&buf, " if (waveEnv != null) {\n") fmt.Fprintf(&buf, " return waveEnv.callBackendService(service, method, args, noUIContext)\n") diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index d3cece18e9..3dad2a3e5c 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -4,7 +4,7 @@ // generated by cmd/generate/main-generatets.go import * as WOS from "./wos"; -import { WaveEnv } from "@/app/waveenv/waveenv"; +import type { WaveEnv } from "@/app/waveenv/waveenv"; function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise { if (waveEnv != null) { diff --git a/frontend/app/store/wps.ts b/frontend/app/store/wps.ts index bda612fc70..332d2ba0a9 100644 --- a/frontend/app/store/wps.ts +++ b/frontend/app/store/wps.ts @@ -3,6 +3,7 @@ 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 }); diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx index f8f74d9cfb..5cc17516ec 100644 --- a/frontend/app/tab/workspaceswitcher.tsx +++ b/frontend/app/tab/workspaceswitcher.tsx @@ -19,8 +19,8 @@ import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { CSSProperties, forwardRef, useCallback, useEffect } from "react"; import WorkspaceSVG from "../asset/workspace.svg"; import { IconButton } from "../element/iconbutton"; -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"; @@ -34,6 +34,10 @@ export type WorkspaceSwitcherEnv = WaveEnvSubset<{ atoms: { workspace: WaveEnv["atoms"]["workspace"]; }; + services: { + workspace: WaveEnv["services"]["workspace"]; + }; + wos: WaveEnv["wos"]; }>; type WorkspaceListEntry = { @@ -53,17 +57,17 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { 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); @@ -96,7 +100,7 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { const saveWorkspace = () => { fireAndForget(async () => { - await WorkspaceService.UpdateWorkspace(activeWorkspace.oid, "", "", "", true); + await env.services.workspace.UpdateWorkspace(activeWorkspace.oid, "", "", "", true); await updateWorkspaceList(); setEditingWorkspace(activeWorkspace.oid); }); @@ -169,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, diff --git a/frontend/app/waveenv/waveenv.ts b/frontend/app/waveenv/waveenv.ts index b57202e115..a2e9d80cf3 100644 --- a/frontend/app/waveenv/waveenv.ts +++ b/frontend/app/waveenv/waveenv.ts @@ -1,6 +1,7 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import type { AllServiceImpls } from "@/app/store/services"; import { RpcApiType } from "@/app/store/wshclientapi"; import { Atom, PrimitiveAtom } from "jotai"; import React from "react"; @@ -32,6 +33,7 @@ type ComplexWaveEnvKeys = { electron: WaveEnv["electron"]; atoms: WaveEnv["atoms"]; wos: WaveEnv["wos"]; + services: WaveEnv["services"]; }; type WaveEnvMockFields = { @@ -60,6 +62,7 @@ export type WaveEnv = { isMacOS: () => boolean; atoms: GlobalAtomsType; createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => Promise; + services: typeof AllServiceImpls; callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => Promise; showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => void; getConnStatusAtom: (conn: string) => PrimitiveAtom; diff --git a/frontend/app/waveenv/waveenvimpl.ts b/frontend/app/waveenv/waveenvimpl.ts index bef0bcbd38..4f9e234eca 100644 --- a/frontend/app/waveenv/waveenvimpl.ts +++ b/frontend/app/waveenv/waveenvimpl.ts @@ -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, @@ -29,6 +30,7 @@ export function makeWaveEnvImpl(): WaveEnv { isMacOS, atoms, createBlock, + services: AllServiceImpls, callBackendService: WOS.callBackendService, showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => { ContextMenuModel.getInstance().showContextMenu(menu, e); diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 032968575a..d8210c97e9 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -4,6 +4,7 @@ import { makeDefaultConnStatus } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { handleWaveEvent } from "@/app/store/wps"; +import { AllServiceTypes } from "@/app/store/services"; import { RpcApiType } from "@/app/store/wshclientapi"; import { WaveEnv } from "@/app/waveenv/waveenv"; import { PlatformMacOS, PlatformWindows } from "@/util/platformutil"; @@ -365,6 +366,7 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { } return connConfigKeyAtomCache.get(cacheKey) as Atom; }, + services: null as any, callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => { const fn = overrides.services?.[service]?.[method]; if (fn) { @@ -376,5 +378,8 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { mockSetWaveObj: mockWosFns.mockSetWaveObj, mockModels: new Map(), } as MockWaveEnv; + env.services = Object.fromEntries( + Object.entries(AllServiceTypes).map(([key, ServiceClass]) => [key, new ServiceClass(env)]) + ) as any; return env; } From dafe23ad11d6277c892cc92ab22d5907e673de25 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 10 Mar 2026 23:25:18 -0700 Subject: [PATCH 16/30] tabbar preview working again --- frontend/preview/previews/tabbar.preview.tsx | 326 +++++-------------- 1 file changed, 86 insertions(+), 240 deletions(-) diff --git a/frontend/preview/previews/tabbar.preview.tsx b/frontend/preview/previews/tabbar.preview.tsx index 23068707a2..ea03e07cfe 100644 --- a/frontend/preview/previews/tabbar.preview.tsx +++ b/frontend/preview/previews/tabbar.preview.tsx @@ -1,18 +1,14 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import WorkspaceSVG from "@/app/asset/workspace.svg"; -import { IconButton } from "@/app/element/iconbutton"; -import { Tooltip } from "@/app/element/tooltip"; import { loadBadges, LoadBadgesEnv } from "@/app/store/badge"; import { getAtoms } from "@/app/store/global-atoms"; -import { Tab } from "@/app/tab/tab"; -import { ConfigErrorIcon, WaveAIButton } from "@/app/tab/tabbar"; +import { globalStore } from "@/app/store/jotaiStore"; +import { TabBar } from "@/app/tab/tabbar"; import { TabBarEnv } from "@/app/tab/tabbarenv"; -import { UpdateStatusBanner } from "@/app/tab/updatebanner"; import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; -import { applyMockEnvOverrides } from "@/preview/mock/mockwaveenv"; -import { useAtom } from "jotai"; +import { applyMockEnvOverrides, MockWaveEnv } from "@/preview/mock/mockwaveenv"; +import { atom, useAtom, useAtomValue } from "jotai"; import { CSSProperties, useEffect, useMemo, useRef, useState } from "react"; type PreviewTabEntry = { @@ -48,15 +44,7 @@ function makeMockBadgeEvents(): BadgeEvent[] { return events; } -const TabDefaultWidth = 130; -const TabMinWidth = 100; -const TabHeight = 26; -const MockWorkspaceSwitcherWidth = 42; -const MockAddTabButtonWidth = 44; -const MockConfigErrors: ConfigError[] = [ - { file: "~/.waveterm/config.json", err: 'unknown preset "bg@aurora"' }, - { file: "~/.waveterm/settings.json", err: "invalid color for tab theme" }, -]; +const MockWorkspaceId = "preview-workspace-1"; const InitialTabs: PreviewTabEntry[] = [ { tabId: "preview-tab-1", tabName: "Terminal" }, { @@ -91,118 +79,93 @@ const InitialTabs: PreviewTabEntry[] = [ { tabId: "preview-tab-6", tabName: "Preview", flagColor: "#bf55ec" }, ]; -function shouldShowAppMenuButton(platform: NodeJS.Platform, showMenuBar: boolean): boolean { - return platform === "win32" || (platform !== "darwin" && !showMenuBar); -} - -function getWindowDragWidths(platform: NodeJS.Platform, isFullScreen: boolean, zoomFactor: number) { - let windowDragLeftWidth = 10; - if (platform === "darwin" && !isFullScreen) { - windowDragLeftWidth = zoomFactor > 0 ? 74 / zoomFactor : 74; - } - - let windowDragRightWidth = 12; - if (platform === "win32") { - windowDragRightWidth = zoomFactor > 0 ? 139 / zoomFactor : 139; - } - - return { windowDragLeftWidth, windowDragRightWidth }; -} - -function MockWorkspaceSwitcher({ divRef }: { divRef: React.RefObject }) { - return ( - -
- -
-
- ); -} - -function MockTabStrip({ - tabs, - activeTabId, - availableWidth, - onSelectTab, - onCloseTab, -}: { - tabs: PreviewTabEntry[]; - activeTabId: string; - availableWidth: number; - onSelectTab: (tabId: string) => void; - onCloseTab: (tabId: string) => void; -}) { - const tabRefs = useRef>({}); - const tabWidth = useMemo(() => { - if (tabs.length === 0) { - return TabDefaultWidth; - } - return Math.max(TabMinWidth, Math.min(availableWidth / tabs.length, TabDefaultWidth)); - }, [availableWidth, tabs.length]); - - useEffect(() => { - tabs.forEach((tab, index) => { - const el = tabRefs.current[tab.tabId]; - if (el == null) { - return; - } - el.style.width = `${tabWidth}px`; - el.style.opacity = "1"; - el.style.transform = `translate3d(${index * tabWidth}px, 0, 0)`; - }); - }, [tabWidth, tabs]); +const MockConfigErrors: ConfigError[] = [ + { file: "~/.waveterm/config.json", err: 'unknown preset "bg@aurora"' }, + { file: "~/.waveterm/settings.json", err: "invalid color for tab theme" }, +]; - return ( -
-
- {tabs.map((tab, index) => { - const activeIndex = tabs.findIndex((item) => item.tabId === activeTabId); - const isActive = tab.tabId === activeTabId; - const showDivider = index !== 0 && !isActive && index !== activeIndex + 1; - return ( - { - tabRefs.current[tab.tabId] = el; - }} - id={tab.tabId} - active={isActive} - showDivider={showDivider} - isDragging={false} - tabWidth={tabWidth} - isNew={false} - onSelect={() => onSelectTab(tab.tabId)} - onClose={() => onCloseTab(tab.tabId)} - onDragStart={() => {}} - onLoaded={() => {}} - /> - ); - })} -
-
- ); +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(null); + const tabEnv = useMemo(() => { - const mockWaveObjs = Object.fromEntries(InitialTabs.map((tab) => [`tab:${tab.tabId}`, makeTabWaveObj(tab)])); - return applyMockEnvOverrides(baseEnv, { + const mockWaveObjs: Record = { + [`workspace:${MockWorkspaceId}`]: makeMockWorkspace(initialTabIds), + }; + for (const tab of InitialTabs) { + mockWaveObjs[`tab:${tab.tabId}`] = makeTabWaveObj(tab); + } + const env = applyMockEnvOverrides(baseEnv, { + tabId: InitialTabs[1].tabId, 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:${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:${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; }, []); + return ( @@ -213,21 +176,13 @@ export function TabBarPreview() { function TabBarPreviewInner() { const env = useWaveEnv(); const loadBadgesEnv = useWaveEnv(); - const [tabs, setTabs] = useState(InitialTabs); - const [activeTabId, setActiveTabId] = useState(InitialTabs[1].tabId); - const [frameWidth, setFrameWidth] = useState(1180); - const [platform, setPlatform] = useState("darwin"); - const [showMenuBar, setShowMenuBar] = useState(false); const [showConfigErrors, setShowConfigErrors] = useState(true); const [hideAiButton, setHideAiButton] = 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(getAtoms().updaterStatusAtom); - const workspaceSwitcherRef = useRef(null); - const waveAIButtonRef = useRef(null); - const updateStatusBannerRef = useRef(null); - const configErrorButtonRef = useRef(null); + const workspace = useAtomValue(env.wos.getWaveObjectAtom(`workspace:${MockWorkspaceId}`)); useEffect(() => { loadBadges(loadBadgesEnv); @@ -239,52 +194,14 @@ function TabBarPreviewInner() { settings: { ...(prev?.settings ?? {}), "app:hideaibutton": hideAiButton, - "window:showmenubar": showMenuBar, }, configerrors: showConfigErrors ? MockConfigErrors : [], })); - }, [hideAiButton, setFullConfig, showConfigErrors, showMenuBar]); - - const showAppMenuButton = shouldShowAppMenuButton(platform, showMenuBar); - const { windowDragLeftWidth, windowDragRightWidth } = getWindowDragWidths(platform, isFullScreen, zoomFactor); - const tabsAvailableWidth = - frameWidth - - windowDragLeftWidth - - windowDragRightWidth - - (showAppMenuButton ? 28 : 0) - - (hideAiButton ? 0 : 48) - - MockWorkspaceSwitcherWidth - - MockAddTabButtonWidth - - (updaterStatus === "up-to-date" ? 0 : 164) - - (showConfigErrors ? 132 : 0) - - 24; + }, [hideAiButton, setFullConfig, showConfigErrors]); return (
- - -
- Double-click a tab name to rename it. Close buttons and context menus are mocked for preview use. + Double-click a tab name to rename it. Close/add buttons and drag reordering are fully functional.
0 ? 1 / zoomFactor : 1, - } as CSSProperties - } + style={{ "--zoomfactor-inv": zoomFactor > 0 ? 1 / zoomFactor : 1 } as CSSProperties} > -
-
- {showAppMenuButton && ( -
- -
- )} - - -
- { - setTabs((prevTabs) => { - const nextTabs = prevTabs.filter((tab) => tab.tabId !== tabId); - if (nextTabs.length === 0) { - return prevTabs; - } - if (activeTabId === tabId) { - setActiveTabId(nextTabs[0].tabId); - } - return nextTabs; - }); - }} - /> -
- { - const previewTabId = `preview-tab-${crypto.randomUUID()}`; - const nextTab = { tabId: previewTabId, tabName: "New Tab" }; - setTabs((prevTabs) => [...prevTabs, nextTab]); - setActiveTabId(previewTabId); - }, - }} - /> -
- - -
-
-
+ {workspace != null && }
- Tabs: {tabs.length} · Active tab: {activeTabId} · Config errors: {fullConfig?.configerrors?.length ?? 0} + Tabs: {workspace?.tabids?.length ?? 0} · Config errors: {fullConfig?.configerrors?.length ?? 0}
); From 8d06b0366a7c848b3b68a36c99368d17a81551a7 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 10 Mar 2026 23:28:57 -0700 Subject: [PATCH 17/30] re-add platform switcher --- frontend/preview/previews/tabbar.preview.tsx | 28 +++++++++++++++++--- frontend/util/platformutil.ts | 1 + 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/frontend/preview/previews/tabbar.preview.tsx b/frontend/preview/previews/tabbar.preview.tsx index ea03e07cfe..715adb4750 100644 --- a/frontend/preview/previews/tabbar.preview.tsx +++ b/frontend/preview/previews/tabbar.preview.tsx @@ -8,6 +8,7 @@ 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"; @@ -100,6 +101,7 @@ export function TabBarPreview() { const baseEnv = useWaveEnv(); const initialTabIds = InitialTabs.map((t) => t.tabId); const envRef = useRef(null); + const [platform, setPlatform] = useState(PlatformMacOS); const tabEnv = useMemo(() => { const mockWaveObjs: Record = { @@ -110,6 +112,7 @@ export function TabBarPreview() { } const env = applyMockEnvOverrides(baseEnv, { tabId: InitialTabs[1].tabId, + platform, mockWaveObjs, atoms: { workspaceId: atom(MockWorkspaceId), @@ -164,16 +167,21 @@ export function TabBarPreview() { }); envRef.current = env; return env; - }, []); + }, [platform]); return ( - + ); } -function TabBarPreviewInner() { +type TabBarPreviewInnerProps = { + platform: NodeJS.Platform; + setPlatform: (platform: NodeJS.Platform) => void; +}; + +function TabBarPreviewInner({ platform, setPlatform }: TabBarPreviewInnerProps) { const env = useWaveEnv(); const loadBadgesEnv = useWaveEnv(); const [showConfigErrors, setShowConfigErrors] = useState(true); @@ -202,6 +210,18 @@ function TabBarPreviewInner() { return (
+
0 ? 1 / zoomFactor : 1 } as CSSProperties} > {workspace != null && } From e9fd7befe2b84f1342c04f1077e33ba63916b7cd Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 11 Mar 2026 10:25:21 -0700 Subject: [PATCH 21/30] add show menu bar option --- frontend/preview/previews/tabbar.preview.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/preview/previews/tabbar.preview.tsx b/frontend/preview/previews/tabbar.preview.tsx index 0d904faad9..17fc10e31e 100644 --- a/frontend/preview/previews/tabbar.preview.tsx +++ b/frontend/preview/previews/tabbar.preview.tsx @@ -186,6 +186,7 @@ function TabBarPreviewInner({ platform, setPlatform }: TabBarPreviewInnerProps) const loadBadgesEnv = useWaveEnv(); const [showConfigErrors, setShowConfigErrors] = useState(true); 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); @@ -202,10 +203,11 @@ function TabBarPreviewInner({ platform, setPlatform }: TabBarPreviewInnerProps) settings: { ...(prev?.settings ?? {}), "app:hideaibutton": hideAiButton, + "window:showmenubar": showMenuBar, }, configerrors: showConfigErrors ? MockConfigErrors : [], })); - }, [hideAiButton, setFullConfig, showConfigErrors]); + }, [hideAiButton, showMenuBar, setFullConfig, showConfigErrors]); return (
@@ -254,6 +256,15 @@ function TabBarPreviewInner({ platform, setPlatform }: TabBarPreviewInnerProps) /> Hide Wave AI button +