+
{innerContent}
);
diff --git a/frontend/app/tab/tabcontextmenu.ts b/frontend/app/tab/tabcontextmenu.ts
new file mode 100644
index 0000000000..5f70bc9b9b
--- /dev/null
+++ b/frontend/app/tab/tabcontextmenu.ts
@@ -0,0 +1,110 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { getOrefMetaKeyAtom, globalStore, recordTEvent } from "@/app/store/global";
+import { TabRpcClient } from "@/app/store/wshrpcutil";
+import { fireAndForget } from "@/util/util";
+import { makeORef } from "../store/wos";
+import type { TabEnv } from "./tab";
+
+const FlagColors: { label: string; value: string }[] = [
+ { label: "Green", value: "#58C142" },
+ { label: "Teal", value: "#00FFDB" },
+ { label: "Blue", value: "#429DFF" },
+ { label: "Purple", value: "#BF55EC" },
+ { label: "Red", value: "#FF453A" },
+ { label: "Orange", value: "#FF9500" },
+ { label: "Yellow", value: "#FFE900" },
+];
+
+export function buildTabBarContextMenu(env: TabEnv): ContextMenuItem[] {
+ const currentTabBar = globalStore.get(env.getSettingsKeyAtom("app:tabbar")) ?? "top";
+ const tabBarSubmenu: ContextMenuItem[] = [
+ {
+ label: "Top",
+ type: "checkbox",
+ checked: currentTabBar === "top",
+ click: () => fireAndForget(() => env.rpc.SetConfigCommand(TabRpcClient, { "app:tabbar": "top" })),
+ },
+ {
+ label: "Left",
+ type: "checkbox",
+ checked: currentTabBar === "left",
+ click: () => fireAndForget(() => env.rpc.SetConfigCommand(TabRpcClient, { "app:tabbar": "left" })),
+ },
+ ];
+ return [{ label: "Tab Bar Position", type: "submenu", submenu: tabBarSubmenu }];
+}
+
+export function buildTabContextMenu(
+ id: string,
+ renameRef: React.RefObject<(() => void) | null>,
+ onClose: (event: React.MouseEvent
| null) => void,
+ env: TabEnv
+): ContextMenuItem[] {
+ const menu: ContextMenuItem[] = [];
+ menu.push(
+ { label: "Rename Tab", click: () => renameRef.current?.() },
+ {
+ label: "Copy TabId",
+ click: () => fireAndForget(() => navigator.clipboard.writeText(id)),
+ },
+ { type: "separator" }
+ );
+ const tabORef = makeORef("tab", id);
+ const currentFlagColor = globalStore.get(getOrefMetaKeyAtom(tabORef, "tab:flagcolor")) ?? null;
+ const flagSubmenu: ContextMenuItem[] = [
+ {
+ label: "None",
+ type: "checkbox",
+ checked: currentFlagColor == 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(() =>
+ 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(env.atoms.fullConfigAtom);
+ const bgPresets: string[] = [];
+ for (const key in fullConfig?.presets ?? {}) {
+ if (key.startsWith("bg@") && fullConfig.presets[key] != null) {
+ bgPresets.push(key);
+ }
+ }
+ bgPresets.sort((a, b) => {
+ const aOrder = fullConfig.presets[a]["display:order"] ?? 0;
+ const bOrder = fullConfig.presets[b]["display:order"] ?? 0;
+ return aOrder - bOrder;
+ });
+ if (bgPresets.length > 0) {
+ const submenu: ContextMenuItem[] = [];
+ const oref = makeORef("tab", id);
+ for (const presetName of bgPresets) {
+ // preset cannot be null (filtered above)
+ const preset = fullConfig.presets[presetName];
+ submenu.push({
+ label: preset["display:name"] ?? presetName,
+ click: () =>
+ fireAndForget(async () => {
+ await env.rpc.SetMetaCommand(TabRpcClient, { oref, meta: preset });
+ env.rpc.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });
+ recordTEvent("action:settabtheme");
+ }),
+ });
+ }
+ menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
+ }
+ menu.push(...buildTabBarContextMenu(env), { type: "separator" });
+ menu.push({ label: "Close Tab", click: () => onClose(null) });
+ return menu;
+}
diff --git a/frontend/app/tab/updatebanner.tsx b/frontend/app/tab/updatebanner.tsx
index 5150c7e338..b589558281 100644
--- a/frontend/app/tab/updatebanner.tsx
+++ b/frontend/app/tab/updatebanner.tsx
@@ -2,11 +2,19 @@
// SPDX-License-Identifier: Apache-2.0
import { Tooltip } from "@/element/tooltip";
-import { useWaveEnv } from "@/app/waveenv/waveenv";
-import { TabBarEnv } from "./tabbarenv";
+import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv";
import { useAtomValue } from "jotai";
import { memo, useCallback } from "react";
+type UpdateBannerEnv = WaveEnvSubset<{
+ electron: {
+ installAppUpdate: WaveEnv["electron"]["installAppUpdate"];
+ };
+ atoms: {
+ updaterStatusAtom: WaveEnv["atoms"]["updaterStatusAtom"];
+ };
+}>;
+
function getUpdateStatusMessage(status: string): string {
switch (status) {
case "ready":
@@ -21,7 +29,7 @@ function getUpdateStatusMessage(status: string): string {
}
const UpdateStatusBannerComponent = () => {
- const env = useWaveEnv();
+ const env = useWaveEnv();
const appUpdateStatus = useAtomValue(env.atoms.updaterStatusAtom);
const updateStatusMessage = getUpdateStatusMessage(appUpdateStatus);
diff --git a/frontend/app/tab/vtab.tsx b/frontend/app/tab/vtab.tsx
index b6c3a29a54..7edd7b0f45 100644
--- a/frontend/app/tab/vtab.tsx
+++ b/frontend/app/tab/vtab.tsx
@@ -19,29 +19,37 @@ export interface VTabItem {
interface VTabProps {
tab: VTabItem;
active: boolean;
+ showDivider?: boolean;
isDragging: boolean;
isReordering: boolean;
onSelect: () => void;
onClose?: () => void;
onRename?: (newName: string) => void;
+ onContextMenu?: (event: React.MouseEvent) => void;
onDragStart: (event: React.DragEvent) => void;
onDragOver: (event: React.DragEvent) => void;
onDrop: (event: React.DragEvent) => void;
onDragEnd: () => void;
+ onHoverChanged?: (isHovered: boolean) => void;
+ renameRef?: React.RefObject<(() => void) | null>;
}
export function VTab({
tab,
active,
+ showDivider = true,
isDragging,
isReordering,
onSelect,
onClose,
onRename,
+ onContextMenu,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
+ onHoverChanged,
+ renameRef,
}: VTabProps) {
const [originalName, setOriginalName] = useState(tab.name);
const [isEditable, setIsEditable] = useState(false);
@@ -100,6 +108,10 @@ export function VTab({
}, RenameFocusDelayMs);
}, [isReordering, onRename, selectEditableText]);
+ if (renameRef != null) {
+ renameRef.current = startRename;
+ }
+
const handleBlur = () => {
if (!editableRef.current) {
return;
@@ -134,36 +146,48 @@ export function VTab({
return (
{
event.stopPropagation();
startRename();
}}
+ onContextMenu={onContextMenu}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
+ onMouseEnter={() => onHoverChanged?.(true)}
+ onMouseLeave={() => onHoverChanged?.(false)}
className={cn(
- "group relative flex h-9 w-full cursor-pointer items-center border-b border-border/70 pl-2 text-sm transition-colors select-none",
+ "group relative flex h-9 w-full shrink-0 cursor-pointer items-center pl-3 text-xs transition-colors select-none",
"whitespace-nowrap",
- active
- ? "bg-accent/20 text-primary"
- : isReordering
- ? "bg-transparent text-secondary"
- : "bg-transparent text-secondary hover:bg-hover",
+ active ? "text-primary" : isReordering ? "text-secondary" : "text-secondary hover:text-primary",
isDragging && "opacity-50"
)}
>
+ {active && (
+
+ )}
+ {!active && !isReordering && (
+
+ )}
+
{
diff --git a/frontend/app/tab/vtabbar.tsx b/frontend/app/tab/vtabbar.tsx
index ad558373dd..05cfea819f 100644
--- a/frontend/app/tab/vtabbar.tsx
+++ b/frontend/app/tab/vtabbar.tsx
@@ -1,51 +1,276 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
-import { cn } from "@/util/util";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { Tooltip } from "@/app/element/tooltip";
+import { getTabBadgeAtom } from "@/app/store/badge";
+import { makeORef } from "@/app/store/wos";
+import { TabRpcClient } from "@/app/store/wshrpcutil";
+import { useWaveEnv } from "@/app/waveenv/waveenv";
+import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
+import { validateCssColor } from "@/util/color-validator";
+import { cn, fireAndForget } from "@/util/util";
+import { useAtomValue } from "jotai";
+import { memo, useCallback, useEffect, useRef, useState } from "react";
+import { buildTabBarContextMenu, buildTabContextMenu } from "./tabcontextmenu";
+import { UpdateStatusBanner } from "./updatebanner";
import { VTab, VTabItem } from "./vtab";
+import { VTabBarEnv } from "./vtabbarenv";
+import { WorkspaceSwitcher } from "./workspaceswitcher";
export type { VTabItem } from "./vtab";
+const VTabBarAIButton = memo(() => {
+ const env = useWaveEnv
();
+ const aiPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().activePanelAtom) === "waveai";
+ const hideAiButton = useAtomValue(env.getSettingsKeyAtom("app:hideaibutton"));
+
+ const onClick = () => {
+ WorkspaceLayoutModel.getInstance().togglePanel("waveai");
+ };
+
+ if (hideAiButton) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+});
+VTabBarAIButton.displayName = "VTabBarAIButton";
+
+const MacOSHeader = memo(() => {
+ const env = useWaveEnv();
+ const isFullScreen = useAtomValue(env.atoms.isFullScreen);
+ return (
+ <>
+ {!isFullScreen && (
+
+ )}
+
+
+
+
+
+
+
+ >
+ );
+});
+MacOSHeader.displayName = "MacOSHeader";
+
interface VTabBarProps {
- tabs: VTabItem[];
- activeTabId?: string;
- width?: number;
+ workspace: Workspace;
className?: string;
- onSelectTab?: (tabId: string) => void;
- onCloseTab?: (tabId: string) => void;
- onRenameTab?: (tabId: string, newName: string) => void;
- onReorderTabs?: (tabIds: string[]) => void;
}
-function clampWidth(width?: number): number {
- if (width == null) {
- return 220;
- }
- if (width < 100) {
- return 100;
- }
- if (width > 400) {
- return 400;
+interface VTabWrapperProps {
+ tabId: string;
+ active: boolean;
+ showDivider: boolean;
+ isDragging: boolean;
+ isReordering: boolean;
+ hoverResetVersion: number;
+ index: number;
+ onSelect: () => void;
+ onClose: () => void;
+ onRename: (newName: string) => void;
+ onDragStart: (event: React.DragEvent) => void;
+ onDragOver: (event: React.DragEvent) => void;
+ onDrop: (event: React.DragEvent) => void;
+ onDragEnd: () => void;
+ onHoverChanged: (isHovered: boolean) => void;
+}
+
+function VTabWrapper({
+ tabId,
+ active,
+ showDivider,
+ isDragging,
+ isReordering,
+ hoverResetVersion,
+ onSelect,
+ onClose,
+ onRename,
+ onDragStart,
+ onDragOver,
+ onDrop,
+ onDragEnd,
+ onHoverChanged,
+}: VTabWrapperProps) {
+ const env = useWaveEnv();
+ const [tabData] = env.wos.useWaveObjectValue(makeORef("tab", tabId));
+ const badges = useAtomValue(getTabBadgeAtom(tabId, env));
+ const renameRef = useRef<(() => void) | null>(null);
+
+ const rawFlagColor = tabData?.meta?.["tab:flagcolor"];
+ let flagColor: string | null = null;
+ if (rawFlagColor) {
+ try {
+ validateCssColor(rawFlagColor);
+ flagColor = rawFlagColor;
+ } catch {
+ flagColor = null;
+ }
}
- return width;
+
+ const tab: VTabItem = {
+ id: tabId,
+ name: tabData?.name ?? "",
+ badges,
+ flagColor,
+ };
+
+ const handleContextMenu = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const menu = buildTabContextMenu(tabId, renameRef, () => onClose(), env);
+ env.showContextMenu(menu, e);
+ },
+ [tabId, onClose, env]
+ );
+
+ return (
+
+ );
}
-export function VTabBar({ tabs, activeTabId, width, className, onSelectTab, onCloseTab, onRenameTab, onReorderTabs }: VTabBarProps) {
- const [orderedTabs, setOrderedTabs] = useState(tabs);
+export function VTabBar({ workspace, className }: VTabBarProps) {
+ const env = useWaveEnv();
+ const activeTabId = useAtomValue(env.atoms.staticTabId);
+ const reinitVersion = useAtomValue(env.atoms.reinitVersion);
+ const documentHasFocus = useAtomValue(env.atoms.documentHasFocus);
+ const tabIds = workspace?.tabids ?? [];
+
+ const [orderedTabIds, setOrderedTabIds] = useState(tabIds);
const [dragTabId, setDragTabId] = useState(null);
const [dropIndex, setDropIndex] = useState(null);
const [dropLineTop, setDropLineTop] = useState(null);
const [hoverResetVersion, setHoverResetVersion] = useState(0);
+ const [hoveredTabId, setHoveredTabId] = useState(null);
+ const [isNewTabHovered, setIsNewTabHovered] = useState(false);
const dragSourceRef = useRef(null);
const didResetHoverForDragRef = useRef(false);
+ const scrollContainerRef = useRef(null);
+ const scrollAnimFrameRef = useRef(null);
+ const scrollDirectionRef = useRef(0);
+ const scrollSpeedRef = useRef(0);
+
+ useEffect(() => {
+ setOrderedTabIds(tabIds);
+ }, [workspace?.tabids]);
+
+ useEffect(() => {
+ if (reinitVersion > 0) {
+ setOrderedTabIds(workspace?.tabids ?? []);
+ }
+ }, [reinitVersion]);
+
+ useEffect(() => {
+ if (activeTabId == null || scrollContainerRef.current == null) {
+ return;
+ }
+ const el = scrollContainerRef.current.querySelector(`[data-tabid="${activeTabId}"]`);
+ el?.scrollIntoView({ block: "nearest" });
+ }, [activeTabId]);
useEffect(() => {
- setOrderedTabs(tabs);
- }, [tabs]);
+ if (!documentHasFocus || activeTabId == null || scrollContainerRef.current == null) {
+ return;
+ }
+ const el = scrollContainerRef.current.querySelector(`[data-tabid="${activeTabId}"]`);
+ el?.scrollIntoView({ block: "nearest" });
+ }, [documentHasFocus]);
- const barWidth = useMemo(() => clampWidth(width), [width]);
+ const stopScrollLoop = useCallback(() => {
+ if (scrollAnimFrameRef.current != null) {
+ cancelAnimationFrame(scrollAnimFrameRef.current);
+ scrollAnimFrameRef.current = null;
+ }
+ scrollDirectionRef.current = 0;
+ }, []);
+
+ const startScrollLoop = useCallback(() => {
+ if (scrollAnimFrameRef.current != null) {
+ return;
+ }
+ const loop = () => {
+ const container = scrollContainerRef.current;
+ if (container == null || scrollDirectionRef.current === 0) {
+ scrollAnimFrameRef.current = null;
+ return;
+ }
+ container.scrollTop += scrollDirectionRef.current * scrollSpeedRef.current;
+ scrollAnimFrameRef.current = requestAnimationFrame(loop);
+ };
+ scrollAnimFrameRef.current = requestAnimationFrame(loop);
+ }, []);
+
+ const updateScrollFromDragY = useCallback(
+ (clientY: number) => {
+ const container = scrollContainerRef.current;
+ if (container == null) {
+ return;
+ }
+ const EdgeZone = 60;
+ const MaxScrollSpeed = 12;
+ const rect = container.getBoundingClientRect();
+ const relY = clientY - rect.top;
+ const height = rect.height;
+ if (relY < EdgeZone) {
+ scrollDirectionRef.current = -1;
+ scrollSpeedRef.current = MaxScrollSpeed * (1 - relY / EdgeZone);
+ startScrollLoop();
+ } else if (relY > height - EdgeZone) {
+ scrollDirectionRef.current = 1;
+ scrollSpeedRef.current = MaxScrollSpeed * (1 - (height - relY) / EdgeZone);
+ startScrollLoop();
+ } else {
+ scrollDirectionRef.current = 0;
+ stopScrollLoop();
+ }
+ },
+ [startScrollLoop, stopScrollLoop]
+ );
const clearDragState = () => {
+ stopScrollLoop();
if (dragSourceRef.current != null && !didResetHoverForDragRef.current) {
didResetHoverForDragRef.current = true;
setHoverResetVersion((version) => version + 1);
@@ -61,33 +286,46 @@ export function VTabBar({ tabs, activeTabId, width, className, onSelectTab, onCl
if (sourceTabId == null) {
return;
}
- const sourceIndex = orderedTabs.findIndex((tab) => tab.id === sourceTabId);
+ const sourceIndex = orderedTabIds.findIndex((id) => id === sourceTabId);
if (sourceIndex === -1) {
return;
}
- const boundedTargetIndex = Math.max(0, Math.min(targetIndex, orderedTabs.length));
+ const boundedTargetIndex = Math.max(0, Math.min(targetIndex, orderedTabIds.length));
const adjustedTargetIndex = sourceIndex < boundedTargetIndex ? boundedTargetIndex - 1 : boundedTargetIndex;
if (sourceIndex === adjustedTargetIndex) {
return;
}
- const nextTabs = [...orderedTabs];
- const [movedTab] = nextTabs.splice(sourceIndex, 1);
- nextTabs.splice(adjustedTargetIndex, 0, movedTab);
- setOrderedTabs(nextTabs);
- onReorderTabs?.(nextTabs.map((tab) => tab.id));
+ const nextTabIds = [...orderedTabIds];
+ const [movedId] = nextTabIds.splice(sourceIndex, 1);
+ nextTabIds.splice(adjustedTargetIndex, 0, movedId);
+ setOrderedTabIds(nextTabIds);
+ fireAndForget(() => env.rpc.UpdateWorkspaceTabIdsCommand(TabRpcClient, workspace.oid, nextTabIds));
};
+ const handleTabBarContextMenu = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ const menu = buildTabBarContextMenu(env);
+ env.showContextMenu(menu, e);
+ },
+ [env]
+ );
+
return (
+ {env.isMacOS() &&
}
{
event.preventDefault();
+ updateScrollFromDragY(event.clientY);
if (event.target === event.currentTarget) {
- setDropIndex(orderedTabs.length);
+ setDropIndex(orderedTabIds.length);
setDropLineTop(event.currentTarget.scrollHeight);
}
}}
@@ -99,48 +337,68 @@ export function VTabBar({ tabs, activeTabId, width, className, onSelectTab, onCl
clearDragState();
}}
>
- {orderedTabs.map((tab, index) => (
-
onSelectTab?.(tab.id)}
- onClose={onCloseTab ? () => onCloseTab(tab.id) : undefined}
- onRename={onRenameTab ? (newName) => onRenameTab(tab.id, newName) : undefined}
- onDragStart={(event) => {
- didResetHoverForDragRef.current = false;
- dragSourceRef.current = tab.id;
- event.dataTransfer.effectAllowed = "move";
- event.dataTransfer.setData("text/plain", tab.id);
- setDragTabId(tab.id);
- setDropIndex(index);
- setDropLineTop(event.currentTarget.offsetTop);
- }}
- onDragOver={(event) => {
- event.preventDefault();
- const rect = event.currentTarget.getBoundingClientRect();
- const relativeY = event.clientY - rect.top;
- const midpoint = event.currentTarget.offsetHeight / 2;
- const insertBefore = relativeY < midpoint;
- setDropIndex(insertBefore ? index : index + 1);
- setDropLineTop(
- insertBefore
- ? event.currentTarget.offsetTop
- : event.currentTarget.offsetTop + event.currentTarget.offsetHeight
- );
- }}
- onDrop={(event) => {
- event.preventDefault();
- if (dropIndex != null) {
- reorder(dropIndex);
+ {orderedTabIds.map((tabId, index) => {
+ const isActive = tabId === activeTabId;
+ const isHovered = tabId === hoveredTabId;
+ const isLast = index === orderedTabIds.length - 1;
+ const nextTabId = orderedTabIds[index + 1];
+ const isNextActive = nextTabId === activeTabId;
+ const isNextHovered = nextTabId === hoveredTabId;
+ return (
+
- ))}
+ isDragging={dragTabId === tabId}
+ isReordering={dragTabId != null}
+ hoverResetVersion={hoverResetVersion}
+ index={index}
+ onSelect={() => env.electron.setActiveTab(tabId)}
+ onClose={() => fireAndForget(() => env.electron.closeTab(workspace.oid, tabId, false))}
+ onRename={(newName) =>
+ fireAndForget(() => env.rpc.UpdateTabNameCommand(TabRpcClient, tabId, newName))
+ }
+ onDragStart={(event) => {
+ didResetHoverForDragRef.current = false;
+ dragSourceRef.current = tabId;
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("text/plain", tabId);
+ setDragTabId(tabId);
+ setDropIndex(index);
+ setDropLineTop(event.currentTarget.offsetTop);
+ }}
+ onDragOver={(event) => {
+ event.preventDefault();
+ const rect = event.currentTarget.getBoundingClientRect();
+ const relativeY = event.clientY - rect.top;
+ const midpoint = event.currentTarget.offsetHeight / 2;
+ const insertBefore = relativeY < midpoint;
+ setDropIndex(insertBefore ? index : index + 1);
+ setDropLineTop(
+ insertBefore
+ ? event.currentTarget.offsetTop
+ : event.currentTarget.offsetTop + event.currentTarget.offsetHeight
+ );
+ }}
+ onDrop={(event) => {
+ event.preventDefault();
+ if (dropIndex != null) {
+ reorder(dropIndex);
+ }
+ clearDragState();
+ }}
+ onDragEnd={clearDragState}
+ onHoverChanged={(isHovered) => setHoveredTabId(isHovered ? tabId : null)}
+ />
+ );
+ })}
{dragTabId != null && dropIndex != null && dropLineTop != null && (
)}
+
env.electron.createTab()}
+ onMouseEnter={() => setIsNewTabHovered(true)}
+ onMouseLeave={() => setIsNewTabHovered(false)}
+ aria-label="New Tab"
+ >
+
+
+ New Tab
+
);
}
diff --git a/frontend/app/tab/vtabbarenv.ts b/frontend/app/tab/vtabbarenv.ts
new file mode 100644
index 0000000000..2533780776
--- /dev/null
+++ b/frontend/app/tab/vtabbarenv.ts
@@ -0,0 +1,41 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
+
+export type VTabBarEnv = WaveEnvSubset<{
+ electron: {
+ createTab: WaveEnv["electron"]["createTab"];
+ closeTab: WaveEnv["electron"]["closeTab"];
+ setActiveTab: WaveEnv["electron"]["setActiveTab"];
+ deleteWorkspace: WaveEnv["electron"]["deleteWorkspace"];
+ createWorkspace: WaveEnv["electron"]["createWorkspace"];
+ switchWorkspace: WaveEnv["electron"]["switchWorkspace"];
+ installAppUpdate: WaveEnv["electron"]["installAppUpdate"];
+ };
+ rpc: {
+ UpdateWorkspaceTabIdsCommand: WaveEnv["rpc"]["UpdateWorkspaceTabIdsCommand"];
+ UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"];
+ ActivityCommand: WaveEnv["rpc"]["ActivityCommand"];
+ SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"];
+ SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"];
+ };
+ atoms: {
+ staticTabId: WaveEnv["atoms"]["staticTabId"];
+ fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"];
+ reinitVersion: WaveEnv["atoms"]["reinitVersion"];
+ documentHasFocus: WaveEnv["atoms"]["documentHasFocus"];
+ workspace: WaveEnv["atoms"]["workspace"];
+ updaterStatusAtom: WaveEnv["atoms"]["updaterStatusAtom"];
+ isFullScreen: WaveEnv["atoms"]["isFullScreen"];
+ };
+ services: {
+ workspace: WaveEnv["services"]["workspace"];
+ };
+ wos: WaveEnv["wos"];
+ showContextMenu: WaveEnv["showContextMenu"];
+ getSettingsKeyAtom: SettingsKeyAtomFnType<"tab:confirmclose" | "app:tabbar" | "app:hideaibutton">;
+ mockSetWaveObj: WaveEnv["mockSetWaveObj"];
+ isWindows: WaveEnv["isWindows"];
+ isMacOS: WaveEnv["isMacOS"];
+}>;
diff --git a/frontend/app/treeview/treeview.test.ts b/frontend/app/treeview/treeview.test.ts
index c286be7a49..10bae3ff1e 100644
--- a/frontend/app/treeview/treeview.test.ts
+++ b/frontend/app/treeview/treeview.test.ts
@@ -25,7 +25,7 @@ describe("treeview visible rows", () => {
expect(rows.map((row) => row.id)).toEqual(["root", "b", "c", "a"]);
});
- it("renders loading and capped synthetic rows", () => {
+ it("renders loading state on node row and capped synthetic rows", () => {
const nodes = makeNodes([
{ id: "root", isDirectory: true, childrenStatus: "loading" },
{
@@ -38,7 +38,8 @@ describe("treeview visible rows", () => {
{ id: "f1", parentId: "dir", isDirectory: false, label: "one.txt" },
]);
const loadingRows = buildVisibleRows(nodes, ["root"], new Set(["root"]));
- expect(loadingRows.map((row) => row.kind)).toEqual(["node", "loading"]);
+ expect(loadingRows.map((row) => row.kind)).toEqual(["node"]);
+ expect(loadingRows[0].isLoading).toBe(true);
const cappedRows = buildVisibleRows(nodes, ["dir"], new Set(["dir"]));
expect(cappedRows.map((row) => row.kind)).toEqual(["node", "node", "capped"]);
diff --git a/frontend/app/treeview/treeview.tsx b/frontend/app/treeview/treeview.tsx
index 4481d2c68f..e9bf9c1f6f 100644
--- a/frontend/app/treeview/treeview.tsx
+++ b/frontend/app/treeview/treeview.tsx
@@ -49,6 +49,7 @@ export interface TreeViewVisibleRow {
isDirectory?: boolean;
isExpanded?: boolean;
hasChildren?: boolean;
+ isLoading?: boolean;
icon?: string;
node?: TreeNodeData;
}
@@ -57,6 +58,7 @@ export interface TreeViewProps {
rootIds: string[];
initialNodes: Record;
fetchDir?: (id: string, limit: number) => Promise;
+ initialExpandedIds?: string[];
maxDirEntries?: number;
rowHeight?: number;
indentWidth?: number;
@@ -66,6 +68,7 @@ export interface TreeViewProps {
width?: number | string;
height?: number | string;
className?: string;
+ expandDirectoriesOnClick?: boolean;
onOpenFile?: (id: string, node: TreeNodeData) => void;
onSelectionChange?: (id: string, node: TreeNodeData) => void;
}
@@ -119,7 +122,9 @@ export function buildVisibleRows(
return;
}
const childIds = node.childrenIds ?? [];
- const hasChildren = node.isDirectory && (childIds.length > 0 || node.childrenStatus !== "loaded");
+ const status = node.childrenStatus ?? "unloaded";
+ const isLoading = status === "loading";
+ const hasChildren = node.isDirectory && (childIds.length > 0 || status !== "loaded");
const isExpanded = expandedIds.has(id);
rows.push({
id,
@@ -130,21 +135,14 @@ export function buildVisibleRows(
isDirectory: node.isDirectory,
isExpanded,
hasChildren,
+ isLoading,
icon: node.icon,
node,
});
if (!isExpanded || !node.isDirectory) {
return;
}
- const status = node.childrenStatus ?? "unloaded";
if (status === "loading") {
- rows.push({
- id: `${id}::__loading`,
- parentId: id,
- depth: depth + 1,
- kind: "loading",
- label: "Loading…",
- });
return;
}
if (status === "error") {
@@ -208,6 +206,7 @@ export const TreeView = forwardRef((props, ref) => {
rootIds,
initialNodes,
fetchDir,
+ initialExpandedIds = [],
maxDirEntries = 500,
rowHeight = DefaultRowHeight,
indentWidth = DefaultIndentWidth,
@@ -217,6 +216,7 @@ export const TreeView = forwardRef((props, ref) => {
width = "100%",
height = 360,
className,
+ expandDirectoriesOnClick = false,
onOpenFile,
onSelectionChange,
} = props;
@@ -226,9 +226,10 @@ export const TreeView = forwardRef((props, ref) => {
Object.entries(initialNodes).map(([id, node]) => [id, { ...node, childrenStatus: node.childrenStatus ?? "unloaded" }])
)
);
- const [expandedIds, setExpandedIds] = useState>(new Set());
+ const [expandedIds, setExpandedIds] = useState>(() => new Set(initialExpandedIds));
const [selectedId, setSelectedId] = useState(rootIds[0]);
const scrollRef = useRef(null);
+ const loadingIdsRef = useRef>(new Set());
useEffect(() => {
setNodesById(
@@ -244,6 +245,10 @@ export const TreeView = forwardRef((props, ref) => {
);
}, [initialNodes]);
+ useEffect(() => {
+ setExpandedIds(new Set(initialExpandedIds));
+ }, [initialExpandedIds]);
+
const visibleRows = useMemo(() => buildVisibleRows(nodesById, rootIds, expandedIds), [nodesById, rootIds, expandedIds]);
const idToIndex = useMemo(
() => new Map(visibleRows.map((row, index) => [row.id, index])),
@@ -287,9 +292,10 @@ export const TreeView = forwardRef((props, ref) => {
return;
}
const status = currentNode.childrenStatus ?? "unloaded";
- if (status !== "unloaded") {
+ if (status !== "unloaded" || loadingIdsRef.current.has(id)) {
return;
}
+ loadingIdsRef.current.add(id);
setNodesById((prev) => {
const next = new Map(prev);
next.set(id, { ...currentNode, childrenStatus: "loading" });
@@ -331,6 +337,8 @@ export const TreeView = forwardRef((props, ref) => {
});
return next;
});
+ } finally {
+ loadingIdsRef.current.delete(id);
}
};
@@ -355,6 +363,19 @@ export const TreeView = forwardRef((props, ref) => {
scrollToId(id);
};
+ useEffect(() => {
+ expandedIds.forEach((id) => {
+ const node = nodesById.get(id);
+ if (node == null || !node.isDirectory) {
+ return;
+ }
+ const status = node.childrenStatus ?? "unloaded";
+ if (status === "unloaded") {
+ void loadChildren(id);
+ }
+ });
+ }, [expandedIds, nodesById]);
+
const selectVisibleNodeAt = (index: number) => {
if (index < 0 || index >= visibleRows.length) {
return;
@@ -455,7 +476,15 @@ export const TreeView = forwardRef((props, ref) => {
height: rowHeight,
transform: `translateY(${virtualRow.start}px)`,
}}
- onClick={() => row.kind === "node" && commitSelection(row.id)}
+ onClick={() => {
+ if (row.kind !== "node") {
+ return;
+ }
+ commitSelection(row.id);
+ if (expandDirectoriesOnClick && row.isDirectory) {
+ toggleExpand(row.id);
+ }
+ }}
onDoubleClick={() => {
if (row.kind !== "node") {
return;
@@ -473,9 +502,13 @@ export const TreeView = forwardRef((props, ref) => {
className="flex items-center"
style={{ paddingLeft: row.depth * indentWidth, width: ChevronWidth + row.depth * indentWidth }}
>
- {row.kind === "node" && row.isDirectory && row.hasChildren ? (
+ {row.kind === "node" && row.isDirectory && row.isLoading ? (
+
+
+
+ ) : row.kind === "node" && row.isDirectory && row.hasChildren ? (
) => {
event.stopPropagation();
toggleExpand(row.id);
diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts
index 9cb1c58720..7d3f6b1b1c 100644
--- a/frontend/app/view/term/term-model.ts
+++ b/frontend/app/view/term/term-model.ts
@@ -285,7 +285,7 @@ export class TermViewModel implements ViewModel {
const isCmd = get(this.isCmdController);
const rtn: IconButtonDecl[] = [];
- const isAIPanelOpen = get(WorkspaceLayoutModel.getInstance().panelVisibleAtom);
+ const isAIPanelOpen = get(WorkspaceLayoutModel.getInstance().activePanelAtom) === "waveai";
if (isAIPanelOpen) {
const shellIntegrationButton = this.getShellIntegrationIconButton(get);
if (shellIntegrationButton) {
diff --git a/frontend/app/view/webview/webview.test.tsx b/frontend/app/view/webview/webview.test.tsx
new file mode 100644
index 0000000000..99302dd2f0
--- /dev/null
+++ b/frontend/app/view/webview/webview.test.tsx
@@ -0,0 +1,20 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import { getWebPreviewDisplayUrl, WebViewPreviewFallback } from "./webview";
+
+describe("webview preview fallback", () => {
+ it("shows the requested URL", () => {
+ const markup = renderToStaticMarkup( );
+
+ expect(markup).toContain("electron webview unavailable");
+ expect(markup).toContain("https://waveterm.dev/docs");
+ });
+
+ it("falls back to about:blank when no URL is available", () => {
+ expect(getWebPreviewDisplayUrl("")).toBe("about:blank");
+ expect(getWebPreviewDisplayUrl(null)).toBe("about:blank");
+ });
+});
diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx
index df50221764..3b10d4cce9 100644
--- a/frontend/app/view/webview/webview.tsx
+++ b/frontend/app/view/webview/webview.tsx
@@ -14,6 +14,7 @@ import {
SuggestionControlNoData,
SuggestionControlNoResults,
} from "@/app/suggestion/suggestion";
+import { MockBoundary } from "@/app/waveenv/mockboundary";
import { WOS, globalStore } from "@/store/global";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import { fireAndForget, useAtomValueSafe } from "@/util/util";
@@ -83,7 +84,7 @@ export class WebViewModel implements ViewModel {
const defaultUrlAtom = getSettingsKeyAtom("web:defaulturl");
this.homepageUrl = atom((get) => {
const defaultUrl = get(defaultUrlAtom);
- const pinnedUrl = get(this.blockAtom).meta.pinnedurl;
+ const pinnedUrl = get(this.blockAtom)?.meta?.pinnedurl;
return pinnedUrl ?? defaultUrl;
});
this.urlWrapperClassName = atom("");
@@ -112,7 +113,7 @@ export class WebViewModel implements ViewModel {
const refreshIcon = get(this.refreshIcon);
const mediaPlaying = get(this.mediaPlaying);
const mediaMuted = get(this.mediaMuted);
- const url = currUrl ?? metaUrl ?? homepageUrl;
+ const url = currUrl ?? metaUrl ?? homepageUrl ?? "";
const rtn: HeaderElem[] = [];
if (get(this.hideNav)) {
return rtn;
@@ -802,13 +803,35 @@ interface WebViewProps {
initialSrc?: string;
}
+function getWebPreviewDisplayUrl(url?: string | null): string {
+ return url?.trim() || "about:blank";
+}
+
+function WebViewPreviewFallback({ url }: { url?: string | null }) {
+ const displayUrl = getWebPreviewDisplayUrl(url);
+
+ return (
+
+
+
preview mock · electron webview unavailable
+
web widget placeholder
+
+ {displayUrl}
+
+
+
+ );
+}
+
const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) => {
const blockData = useAtomValue(model.blockAtom);
const defaultUrl = useAtomValue(model.homepageUrl);
const defaultSearchAtom = getSettingsKeyAtom("web:defaultsearch");
const defaultSearch = useAtomValue(defaultSearchAtom);
- let metaUrl = blockData?.meta?.url || defaultUrl;
- metaUrl = model.ensureUrlScheme(metaUrl, defaultSearch);
+ let metaUrl = blockData?.meta?.url || defaultUrl || "";
+ if (metaUrl) {
+ metaUrl = model.ensureUrlScheme(metaUrl, defaultSearch);
+ }
const metaUrlRef = useRef(metaUrl);
const zoomFactor = useAtomValue(getBlockMetaKeyAtom(model.blockId, "web:zoom")) || 1;
const partitionOverride = useAtomValueSafe(model.partitionOverride);
@@ -1055,19 +1078,21 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
return (
-
+ }>
+
+
{errorText && (
{errorText}
@@ -1079,4 +1104,4 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
);
});
-export { WebView };
+export { getWebPreviewDisplayUrl, WebView, WebViewPreviewFallback };
diff --git a/frontend/app/workspace/workspace-layout-model.ts b/frontend/app/workspace/workspace-layout-model.ts
index 725c9a17b5..a87019503c 100644
--- a/frontend/app/workspace/workspace-layout-model.ts
+++ b/frontend/app/workspace/workspace-layout-model.ts
@@ -1,13 +1,14 @@
-// Copyright 2025, Command Line Inc.
+// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { WaveAIModel } from "@/app/aipanel/waveai-model";
import { globalStore } from "@/app/store/jotaiStore";
+import { isBuilderWindow } from "@/app/store/windowtype";
import * as WOS from "@/app/store/wos";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks";
-import { atoms, getApi, getOrefMetaKeyAtom, recordTEvent, refocusNode } from "@/store/global";
+import { atoms, getApi, getOrefMetaKeyAtom, isDev, recordTEvent, refocusNode } from "@/store/global";
import debug from "debug";
import * as jotai from "jotai";
import { debounce } from "lodash-es";
@@ -15,48 +16,90 @@ import { ImperativePanelGroupHandle, ImperativePanelHandle } from "react-resizab
const dlog = debug("wave:workspace");
-const AIPANEL_DEFAULTWIDTH = 300;
-const AIPANEL_DEFAULTWIDTHRATIO = 0.33;
-const AIPANEL_MINWIDTH = 300;
-const AIPANEL_MAXWIDTHRATIO = 0.66;
+const AIPanel_DefaultWidth = 300;
+const AIPanel_DefaultWidthRatio = 0.33;
+const AIPanel_MinWidth = 300;
+const AIPanel_MaxWidthRatio = 0.66;
+
+const VTabBar_DefaultWidth = 220;
+const VTabBar_MinWidth = 110;
+const VTabBar_MaxWidth = 280;
+
+type SidePanelView = "waveai" | "fileexplorer";
+
+function clampVTabWidth(w: number): number {
+ return Math.max(VTabBar_MinWidth, Math.min(w, VTabBar_MaxWidth));
+}
+
+function clampAIPanelWidth(w: number, windowWidth: number): number {
+ const maxWidth = Math.floor(windowWidth * AIPanel_MaxWidthRatio);
+ if (AIPanel_MinWidth > maxWidth) return AIPanel_MinWidth;
+ return Math.max(AIPanel_MinWidth, Math.min(w, maxWidth));
+}
class WorkspaceLayoutModel {
private static instance: WorkspaceLayoutModel | null = null;
aiPanelRef: ImperativePanelHandle | null;
- panelGroupRef: ImperativePanelGroupHandle | null;
+ vtabPanelRef: ImperativePanelHandle | null;
+ outerPanelGroupRef: ImperativePanelGroupHandle | null;
+ innerPanelGroupRef: ImperativePanelGroupHandle | null;
panelContainerRef: HTMLDivElement | null;
aiPanelWrapperRef: HTMLDivElement | null;
- inResize: boolean; // prevents recursive setLayout calls (setLayout triggers onLayout which calls setLayout)
+ panelVisibleAtom: jotai.PrimitiveAtom
;
+ vtabVisibleAtom: jotai.PrimitiveAtom;
+ activePanelAtom: jotai.PrimitiveAtom;
+
+ private inResize: boolean;
private aiPanelVisible: boolean;
private aiPanelWidth: number | null;
- private debouncedPersistWidth: (width: number) => void;
+ private vtabWidth: number;
+ private vtabVisible: boolean;
private initialized: boolean = false;
private transitionTimeoutRef: NodeJS.Timeout | null = null;
private focusTimeoutRef: NodeJS.Timeout | null = null;
- panelVisibleAtom: jotai.PrimitiveAtom;
+ private debouncedPersistAIWidth: (width: number) => void;
+ private debouncedPersistVTabWidth: (width: number) => void;
private constructor() {
this.aiPanelRef = null;
- this.panelGroupRef = null;
+ this.vtabPanelRef = null;
+ this.outerPanelGroupRef = null;
+ this.innerPanelGroupRef = null;
this.panelContainerRef = null;
this.aiPanelWrapperRef = null;
this.inResize = false;
this.aiPanelVisible = false;
this.aiPanelWidth = null;
- this.panelVisibleAtom = jotai.atom(this.aiPanelVisible);
+ this.vtabWidth = VTabBar_DefaultWidth;
+ this.vtabVisible = false;
+ this.panelVisibleAtom = jotai.atom(false);
+ this.vtabVisibleAtom = jotai.atom(false);
+ this.activePanelAtom = jotai.atom(null) as jotai.PrimitiveAtom;
this.handleWindowResize = this.handleWindowResize.bind(this);
- this.handlePanelLayout = this.handlePanelLayout.bind(this);
+ this.handleOuterPanelLayout = this.handleOuterPanelLayout.bind(this);
+ this.handleInnerPanelLayout = this.handleInnerPanelLayout.bind(this);
- this.debouncedPersistWidth = debounce((width: number) => {
+ this.debouncedPersistAIWidth = debounce((width: number) => {
try {
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("tab", this.getTabId()),
meta: { "waveai:panelwidth": width },
});
} catch (e) {
- console.warn("Failed to persist panel width:", e);
+ console.warn("Failed to persist AI panel width:", e);
+ }
+ }, 300);
+
+ this.debouncedPersistVTabWidth = debounce((width: number) => {
+ try {
+ RpcApi.SetMetaCommand(TabRpcClient, {
+ oref: WOS.makeORef("workspace", this.getWorkspaceId()),
+ meta: { "layout:vtabbarwidth": width },
+ });
+ } catch (e) {
+ console.warn("Failed to persist vtabbar width:", e);
}
}, 300);
}
@@ -68,79 +111,204 @@ class WorkspaceLayoutModel {
return WorkspaceLayoutModel.instance;
}
- private initializeFromTabMeta(): void {
+ private getTabId(): string {
+ return globalStore.get(atoms.staticTabId);
+ }
+
+ private getWorkspaceId(): string {
+ return globalStore.get(atoms.workspace)?.oid ?? "";
+ }
+
+ private getPanelOpenAtom(): jotai.Atom {
+ return getOrefMetaKeyAtom(WOS.makeORef("tab", this.getTabId()), "waveai:panelopen");
+ }
+
+ private getPanelWidthAtom(): jotai.Atom {
+ return getOrefMetaKeyAtom(WOS.makeORef("tab", this.getTabId()), "waveai:panelwidth");
+ }
+
+ private getVTabBarWidthAtom(): jotai.Atom {
+ return getOrefMetaKeyAtom(WOS.makeORef("workspace", this.getWorkspaceId()), "layout:vtabbarwidth");
+ }
+
+ private initializeFromMeta(): void {
if (this.initialized) return;
this.initialized = true;
-
try {
const savedVisible = globalStore.get(this.getPanelOpenAtom());
- const savedWidth = globalStore.get(this.getPanelWidthAtom());
-
+ const savedAIWidth = globalStore.get(this.getPanelWidthAtom());
+ const savedVTabWidth = globalStore.get(this.getVTabBarWidthAtom());
if (savedVisible != null) {
this.aiPanelVisible = savedVisible;
globalStore.set(this.panelVisibleAtom, savedVisible);
+ globalStore.set(this.activePanelAtom, savedVisible ? "waveai" : null);
}
- if (savedWidth != null) {
- this.aiPanelWidth = savedWidth;
+ if (savedAIWidth != null) {
+ this.aiPanelWidth = savedAIWidth;
+ }
+ if (savedVTabWidth != null && savedVTabWidth > 0) {
+ this.vtabWidth = savedVTabWidth;
}
} catch (e) {
console.warn("Failed to initialize from tab meta:", e);
}
}
- private getTabId(): string {
- return globalStore.get(atoms.staticTabId);
+ private getResolvedAIWidth(windowWidth: number): number {
+ this.initializeFromMeta();
+ let w = this.aiPanelWidth;
+ if (w == null) {
+ w = Math.max(AIPanel_DefaultWidth, windowWidth * AIPanel_DefaultWidthRatio);
+ this.aiPanelWidth = w;
+ }
+ return clampAIPanelWidth(w, windowWidth);
}
- private getPanelOpenAtom(): jotai.Atom {
- const tabORef = WOS.makeORef("tab", this.getTabId());
- return getOrefMetaKeyAtom(tabORef, "waveai:panelopen");
+ private getResolvedVTabWidth(): number {
+ this.initializeFromMeta();
+ return clampVTabWidth(this.vtabWidth);
}
- private getPanelWidthAtom(): jotai.Atom {
- const tabORef = WOS.makeORef("tab", this.getTabId());
- return getOrefMetaKeyAtom(tabORef, "waveai:panelwidth");
+ private computeLayout(windowWidth: number): { outer: number[]; inner: number[] } {
+ const vtabW = this.vtabVisible ? this.getResolvedVTabWidth() : 0;
+ const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0;
+ const leftGroupW = vtabW + aiW;
+
+ const leftPct = windowWidth > 0 ? (leftGroupW / windowWidth) * 100 : 0;
+ const contentPct = Math.max(0, 100 - leftPct);
+
+ let vtabPct: number;
+ let aiPct: number;
+ if (leftGroupW > 0) {
+ vtabPct = (vtabW / leftGroupW) * 100;
+ aiPct = 100 - vtabPct;
+ } else {
+ vtabPct = 50;
+ aiPct = 50;
+ }
+
+ return { outer: [leftPct, contentPct], inner: [vtabPct, aiPct] };
+ }
+
+ private commitLayouts(windowWidth: number): void {
+ if (!this.outerPanelGroupRef || !this.innerPanelGroupRef) return;
+ const { outer, inner } = this.computeLayout(windowWidth);
+ this.inResize = true;
+ this.outerPanelGroupRef.setLayout(outer);
+ this.innerPanelGroupRef.setLayout(inner);
+ this.inResize = false;
+ this.updateWrapperWidth();
+ }
+
+ handleOuterPanelLayout(sizes: number[]): void {
+ if (this.inResize) return;
+ const windowWidth = window.innerWidth;
+ const newLeftGroupPx = (sizes[0] / 100) * windowWidth;
+
+ if (this.vtabVisible && this.aiPanelVisible) {
+ const vtabW = this.getResolvedVTabWidth();
+ const newAIW = clampAIPanelWidth(newLeftGroupPx - vtabW, windowWidth);
+ this.aiPanelWidth = newAIW;
+ this.debouncedPersistAIWidth(newAIW);
+ } else if (this.vtabVisible) {
+ const clamped = clampVTabWidth(newLeftGroupPx);
+ this.vtabWidth = clamped;
+ this.debouncedPersistVTabWidth(clamped);
+ } else if (this.aiPanelVisible) {
+ const clamped = clampAIPanelWidth(newLeftGroupPx, windowWidth);
+ this.aiPanelWidth = clamped;
+ this.debouncedPersistAIWidth(clamped);
+ }
+
+ this.commitLayouts(windowWidth);
+ }
+
+ handleInnerPanelLayout(sizes: number[]): void {
+ if (this.inResize) return;
+ if (!this.vtabVisible || !this.aiPanelVisible) return;
+
+ const windowWidth = window.innerWidth;
+ const vtabW = this.getResolvedVTabWidth();
+ const aiW = this.getResolvedAIWidth(windowWidth);
+ const leftGroupW = vtabW + aiW;
+
+ const newVTabW = (sizes[0] / 100) * leftGroupW;
+ const clampedVTab = clampVTabWidth(newVTabW);
+ const newAIW = clampAIPanelWidth(leftGroupW - clampedVTab, windowWidth);
+
+ if (clampedVTab !== this.vtabWidth) {
+ this.vtabWidth = clampedVTab;
+ this.debouncedPersistVTabWidth(clampedVTab);
+ }
+ if (newAIW !== this.aiPanelWidth) {
+ this.aiPanelWidth = newAIW;
+ this.debouncedPersistAIWidth(newAIW);
+ }
+
+ this.commitLayouts(windowWidth);
+ }
+
+ handleWindowResize(): void {
+ this.commitLayouts(window.innerWidth);
+ }
+
+ syncVTabWidthFromMeta(): void {
+ const savedVTabWidth = globalStore.get(this.getVTabBarWidthAtom());
+ if (savedVTabWidth != null && savedVTabWidth > 0 && savedVTabWidth !== this.vtabWidth) {
+ this.vtabWidth = savedVTabWidth;
+ this.commitLayouts(window.innerWidth);
+ }
}
registerRefs(
aiPanelRef: ImperativePanelHandle,
- panelGroupRef: ImperativePanelGroupHandle,
+ outerPanelGroupRef: ImperativePanelGroupHandle,
+ innerPanelGroupRef: ImperativePanelGroupHandle,
panelContainerRef: HTMLDivElement,
- aiPanelWrapperRef: HTMLDivElement
+ aiPanelWrapperRef: HTMLDivElement,
+ vtabPanelRef?: ImperativePanelHandle,
+ showLeftTabBar?: boolean
): void {
this.aiPanelRef = aiPanelRef;
- this.panelGroupRef = panelGroupRef;
+ this.vtabPanelRef = vtabPanelRef ?? null;
+ this.outerPanelGroupRef = outerPanelGroupRef;
+ this.innerPanelGroupRef = innerPanelGroupRef;
this.panelContainerRef = panelContainerRef;
this.aiPanelWrapperRef = aiPanelWrapperRef;
- this.syncAIPanelRef();
- this.updateWrapperWidth();
+ this.vtabVisible = showLeftTabBar ?? false;
+ globalStore.set(this.vtabVisibleAtom, this.vtabVisible);
+ this.syncPanelCollapse();
+ this.commitLayouts(window.innerWidth);
}
- updateWrapperWidth(): void {
- if (!this.aiPanelWrapperRef) {
- return;
+ private syncPanelCollapse(): void {
+ if (this.aiPanelRef) {
+ if (this.aiPanelVisible) {
+ this.aiPanelRef.expand();
+ } else {
+ this.aiPanelRef.collapse();
+ }
+ }
+ if (this.vtabPanelRef) {
+ if (this.vtabVisible) {
+ this.vtabPanelRef.expand();
+ } else {
+ this.vtabPanelRef.collapse();
+ }
}
- const width = this.getAIPanelWidth();
- const clampedWidth = this.getClampedAIPanelWidth(width, window.innerWidth);
- this.aiPanelWrapperRef.style.width = `${clampedWidth}px`;
}
enableTransitions(duration: number): void {
- if (!this.panelContainerRef) {
- return;
- }
+ if (!this.panelContainerRef) return;
const panels = this.panelContainerRef.querySelectorAll("[data-panel]");
panels.forEach((panel: HTMLElement) => {
panel.style.transition = "flex 0.2s ease-in-out";
});
-
if (this.transitionTimeoutRef) {
clearTimeout(this.transitionTimeoutRef);
}
this.transitionTimeoutRef = setTimeout(() => {
- if (!this.panelContainerRef) {
- return;
- }
+ if (!this.panelContainerRef) return;
const panels = this.panelContainerRef.querySelectorAll("[data-panel]");
panels.forEach((panel: HTMLElement) => {
panel.style.transition = "none";
@@ -148,154 +316,152 @@ class WorkspaceLayoutModel {
}, duration);
}
- handleWindowResize(): void {
- if (!this.panelGroupRef) {
- return;
- }
- const newWindowWidth = window.innerWidth;
- const aiPanelPercentage = this.getAIPanelPercentage(newWindowWidth);
- const mainContentPercentage = this.getMainContentPercentage(newWindowWidth);
- this.inResize = true;
- const layout = [aiPanelPercentage, mainContentPercentage];
- this.panelGroupRef.setLayout(layout);
- this.inResize = false;
- this.updateWrapperWidth();
+ updateWrapperWidth(): void {
+ if (!this.aiPanelWrapperRef) return;
+ const width = this.getResolvedAIWidth(window.innerWidth);
+ this.aiPanelWrapperRef.style.width = `${width}px`;
}
- handlePanelLayout(sizes: number[]): void {
- // dlog("handlePanelLayout", "inResize:", this.inResize, "sizes:", sizes);
- if (this.inResize) {
- return;
- }
- if (!this.panelGroupRef) {
- return;
- }
-
- const currentWindowWidth = window.innerWidth;
- const aiPanelPixelWidth = (sizes[0] / 100) * currentWindowWidth;
- this.handleAIPanelResize(aiPanelPixelWidth, currentWindowWidth);
- const newPercentage = this.getAIPanelPercentage(currentWindowWidth);
- const mainContentPercentage = 100 - newPercentage;
- this.inResize = true;
- const layout = [newPercentage, mainContentPercentage];
- this.panelGroupRef.setLayout(layout);
- this.inResize = false;
+ getAIPanelVisible(): boolean {
+ this.initializeFromMeta();
+ return this.aiPanelVisible;
}
- syncAIPanelRef(): void {
- if (!this.aiPanelRef || !this.panelGroupRef) {
- return;
- }
-
- const currentWindowWidth = window.innerWidth;
- const aiPanelPercentage = this.getAIPanelPercentage(currentWindowWidth);
- const mainContentPercentage = this.getMainContentPercentage(currentWindowWidth);
-
- if (this.getAIPanelVisible()) {
- this.aiPanelRef.expand();
- } else {
- this.aiPanelRef.collapse();
- }
+ getAIPanelWidth(): number {
+ return this.getResolvedAIWidth(window.innerWidth);
+ }
- this.inResize = true;
- const layout = [aiPanelPercentage, mainContentPercentage];
- this.panelGroupRef.setLayout(layout);
- this.inResize = false;
+ getActivePanel(): SidePanelView | null {
+ return globalStore.get(this.activePanelAtom);
}
- getMaxAIPanelWidth(windowWidth: number): number {
- return Math.floor(windowWidth * AIPANEL_MAXWIDTHRATIO);
+ getLeftGroupInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number {
+ this.initializeFromMeta();
+ const vtabW = showLeftTabBar && !isBuilderWindow() ? this.getResolvedVTabWidth() : 0;
+ const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0;
+ return ((vtabW + aiW) / windowWidth) * 100;
}
- getClampedAIPanelWidth(width: number, windowWidth: number): number {
- const maxWidth = this.getMaxAIPanelWidth(windowWidth);
- if (AIPANEL_MINWIDTH > maxWidth) {
- return AIPANEL_MINWIDTH;
- }
- return Math.max(AIPANEL_MINWIDTH, Math.min(width, maxWidth));
+ getInnerVTabInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number {
+ if (!showLeftTabBar || isBuilderWindow()) return 0;
+ this.initializeFromMeta();
+ const vtabW = this.getResolvedVTabWidth();
+ const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0;
+ const total = vtabW + aiW;
+ if (total === 0) return 50;
+ return (vtabW / total) * 100;
}
- getAIPanelVisible(): boolean {
- this.initializeFromTabMeta();
- return this.aiPanelVisible;
+ getInnerAIPanelInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number {
+ this.initializeFromMeta();
+ const vtabW = showLeftTabBar && !isBuilderWindow() ? this.getResolvedVTabWidth() : 0;
+ const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0;
+ const total = vtabW + aiW;
+ if (total === 0) return 50;
+ return (aiW / total) * 100;
}
- setAIPanelVisible(visible: boolean, opts?: { nofocus?: boolean }): void {
+ openPanel(panel: SidePanelView, opts?: { nofocus?: boolean }): void {
+ if (!isDev() && panel !== "waveai") {
+ return;
+ }
if (this.focusTimeoutRef != null) {
clearTimeout(this.focusTimeoutRef);
this.focusTimeoutRef = null;
}
const wasVisible = this.aiPanelVisible;
- this.aiPanelVisible = visible;
- if (visible && !wasVisible) {
- recordTEvent("action:openwaveai");
+ this.aiPanelVisible = true;
+ globalStore.set(this.activePanelAtom, panel);
+ globalStore.set(this.panelVisibleAtom, true);
+ if (!wasVisible) {
+ if (panel === "waveai") {
+ recordTEvent("action:openwaveai");
+ } else if (panel === "fileexplorer") {
+ recordTEvent("action:openfileexplorer");
+ }
}
- globalStore.set(this.panelVisibleAtom, visible);
- getApi().setWaveAIOpen(visible);
+ getApi().setWaveAIOpen(true);
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("tab", this.getTabId()),
- meta: { "waveai:panelopen": visible },
+ meta: { "waveai:panelopen": true },
});
this.enableTransitions(250);
- this.syncAIPanelRef();
-
- if (visible) {
- if (!opts?.nofocus) {
- this.focusTimeoutRef = setTimeout(() => {
- WaveAIModel.getInstance().focusInput();
- this.focusTimeoutRef = null;
- }, 350);
- }
- } else {
- const layoutModel = getLayoutModelForStaticTab();
- const focusedNode = globalStore.get(layoutModel.focusedNode);
- if (focusedNode == null) {
- layoutModel.focusFirstNode();
- return;
- }
- const blockId = focusedNode?.data?.blockId;
- if (blockId != null) {
- refocusNode(blockId);
- }
+ this.syncPanelCollapse();
+ this.commitLayouts(window.innerWidth);
+
+ if (panel === "waveai" && !opts?.nofocus) {
+ this.focusTimeoutRef = setTimeout(() => {
+ WaveAIModel.getInstance().focusInput();
+ this.focusTimeoutRef = null;
+ }, 350);
}
}
- getAIPanelWidth(): number {
- this.initializeFromTabMeta();
- if (this.aiPanelWidth == null) {
- this.aiPanelWidth = Math.max(AIPANEL_DEFAULTWIDTH, window.innerWidth * AIPANEL_DEFAULTWIDTHRATIO);
+ closePanel(): void {
+ if (this.focusTimeoutRef != null) {
+ clearTimeout(this.focusTimeoutRef);
+ this.focusTimeoutRef = null;
}
- return this.aiPanelWidth;
- }
+ this.aiPanelVisible = false;
+ globalStore.set(this.activePanelAtom, null);
+ globalStore.set(this.panelVisibleAtom, false);
+ getApi().setWaveAIOpen(false);
+ RpcApi.SetMetaCommand(TabRpcClient, {
+ oref: WOS.makeORef("tab", this.getTabId()),
+ meta: { "waveai:panelopen": false },
+ });
+ this.enableTransitions(250);
+ this.syncPanelCollapse();
+ this.commitLayouts(window.innerWidth);
- setAIPanelWidth(width: number): void {
- this.aiPanelWidth = width;
- this.updateWrapperWidth();
- this.debouncedPersistWidth(width);
+ const layoutModel = getLayoutModelForStaticTab();
+ const focusedNode = globalStore.get(layoutModel.focusedNode);
+ if (focusedNode == null) {
+ layoutModel.focusFirstNode();
+ return;
+ }
+ const blockId = focusedNode?.data?.blockId;
+ if (blockId != null) {
+ refocusNode(blockId);
+ }
}
- getAIPanelPercentage(windowWidth: number): number {
- const isVisible = this.getAIPanelVisible();
- if (!isVisible) {
- return 0;
+ togglePanel(panel: SidePanelView, opts?: { nofocus?: boolean }): void {
+ if (this.getActivePanel() === panel) {
+ this.closePanel();
+ } else {
+ this.openPanel(panel, opts);
}
- const aiPanelWidth = this.getAIPanelWidth();
- const clampedWidth = this.getClampedAIPanelWidth(aiPanelWidth, windowWidth);
- const percentage = (clampedWidth / windowWidth) * 100;
- return Math.max(0, Math.min(percentage, 100));
}
- getMainContentPercentage(windowWidth: number): number {
- const aiPanelPercentage = this.getAIPanelPercentage(windowWidth);
- return Math.max(0, 100 - aiPanelPercentage);
+ setAIPanelVisible(visible: boolean, opts?: { nofocus?: boolean }): void {
+ if (visible) {
+ this.openPanel("waveai", opts);
+ } else {
+ this.closePanel();
+ }
}
- handleAIPanelResize(width: number, windowWidth: number): void {
- if (!this.getAIPanelVisible()) {
+ setFileExplorerPanelVisible(visible: boolean): void {
+ if (!isDev()) {
+ return;
+ }
+ if (visible) {
+ this.openPanel("fileexplorer", { nofocus: true });
return;
}
- const clampedWidth = this.getClampedAIPanelWidth(width, windowWidth);
- this.setAIPanelWidth(clampedWidth);
+ if (this.getActivePanel() === "fileexplorer") {
+ this.closePanel();
+ }
+ }
+
+ setShowLeftTabBar(showLeftTabBar: boolean): void {
+ if (this.vtabVisible === showLeftTabBar) return;
+ this.vtabVisible = showLeftTabBar;
+ globalStore.set(this.vtabVisibleAtom, showLeftTabBar);
+ this.enableTransitions(250);
+ this.syncPanelCollapse();
+ this.commitLayouts(window.innerWidth);
}
}
diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx
index fb1d78668f..1904b1e101 100644
--- a/frontend/app/workspace/workspace.tsx
+++ b/frontend/app/workspace/workspace.tsx
@@ -1,15 +1,18 @@
-// Copyright 2025, Command Line Inc.
+// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { AIPanel } from "@/app/aipanel/aipanel";
import { ErrorBoundary } from "@/app/element/errorboundary";
import { CenteredDiv } from "@/app/element/quickelems";
+import { FileExplorerPanel } from "@/app/fileexplorer/fileexplorer";
import { ModalsRenderer } from "@/app/modals/modalsrenderer";
import { TabBar } from "@/app/tab/tabbar";
import { TabContent } from "@/app/tab/tabcontent";
-import { Widgets } from "@/app/workspace/widgets";
+import { VTabBar } from "@/app/tab/vtabbar";
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
-import { atoms, getApi } from "@/store/global";
+import { Widgets } from "@/app/workspace/widgets";
+import { atoms, getApi, getSettingsKeyAtom } from "@/store/global";
+import { isMacOS } from "@/util/platformutil";
import { useAtomValue } from "jotai";
import { memo, useEffect, useRef } from "react";
import {
@@ -20,23 +23,59 @@ import {
PanelResizeHandle,
} from "react-resizable-panels";
+const MacOSTabBarSpacer = memo(() => {
+ return (
+
+ );
+});
+MacOSTabBarSpacer.displayName = "MacOSTabBarSpacer";
+
const WorkspaceElem = memo(() => {
const workspaceLayoutModel = WorkspaceLayoutModel.getInstance();
const tabId = useAtomValue(atoms.staticTabId);
const ws = useAtomValue(atoms.workspace);
- const initialAiPanelPercentage = workspaceLayoutModel.getAIPanelPercentage(window.innerWidth);
- const panelGroupRef = useRef(null);
+ const tabBarPosition = useAtomValue(getSettingsKeyAtom("app:tabbar")) ?? "top";
+ const showLeftTabBar = tabBarPosition === "left";
+ const activePanel = useAtomValue(workspaceLayoutModel.activePanelAtom);
+ const aiPanelVisible = useAtomValue(workspaceLayoutModel.panelVisibleAtom);
+ const vtabVisible = useAtomValue(workspaceLayoutModel.vtabVisibleAtom);
+ const windowWidth = window.innerWidth;
+ const leftGroupInitialPct = workspaceLayoutModel.getLeftGroupInitialPercentage(windowWidth, showLeftTabBar);
+ const innerVTabInitialPct = workspaceLayoutModel.getInnerVTabInitialPercentage(windowWidth, showLeftTabBar);
+ const innerAIPanelInitialPct = workspaceLayoutModel.getInnerAIPanelInitialPercentage(windowWidth, showLeftTabBar);
+ const outerPanelGroupRef = useRef(null);
+ const innerPanelGroupRef = useRef(null);
const aiPanelRef = useRef(null);
+ const vtabPanelRef = useRef(null);
const panelContainerRef = useRef(null);
const aiPanelWrapperRef = useRef(null);
useEffect(() => {
- if (aiPanelRef.current && panelGroupRef.current && panelContainerRef.current && aiPanelWrapperRef.current) {
+ if (
+ aiPanelRef.current &&
+ outerPanelGroupRef.current &&
+ innerPanelGroupRef.current &&
+ panelContainerRef.current &&
+ aiPanelWrapperRef.current
+ ) {
workspaceLayoutModel.registerRefs(
aiPanelRef.current,
- panelGroupRef.current,
+ outerPanelGroupRef.current,
+ innerPanelGroupRef.current,
panelContainerRef.current,
- aiPanelWrapperRef.current
+ aiPanelWrapperRef.current,
+ vtabPanelRef.current ?? undefined,
+ showLeftTabBar
);
}
}, []);
@@ -46,39 +85,81 @@ const WorkspaceElem = memo(() => {
getApi().setWaveAIOpen(isVisible);
}, []);
+ useEffect(() => {
+ workspaceLayoutModel.setShowLeftTabBar(showLeftTabBar);
+ }, [showLeftTabBar]);
+
useEffect(() => {
window.addEventListener("resize", workspaceLayoutModel.handleWindowResize);
return () => window.removeEventListener("resize", workspaceLayoutModel.handleWindowResize);
}, []);
+ useEffect(() => {
+ const handleFocus = () => workspaceLayoutModel.syncVTabWidthFromMeta();
+ window.addEventListener("focus", handleFocus);
+ return () => window.removeEventListener("focus", handleFocus);
+ }, []);
+
+ const innerHandleVisible = vtabVisible && aiPanelVisible;
+ const innerHandleClass = `bg-transparent hover:bg-zinc-500/20 transition-colors ${innerHandleVisible ? "w-0.5" : "w-0 pointer-events-none"}`;
+ const outerHandleVisible = vtabVisible || aiPanelVisible;
+ const outerHandleClass = `bg-transparent hover:bg-zinc-500/20 transition-colors ${outerHandleVisible ? "w-0.5" : "w-0 pointer-events-none"}`;
+
return (
-
+ {!(showLeftTabBar && isMacOS()) &&
}
+ {showLeftTabBar && isMacOS() &&
}
-
-
+
+
+
+ {showLeftTabBar && }
+
+
+
+
+ {tabId !== "" &&
+ (activePanel === "fileexplorer" ? (
+
+ ) : (
+
+ ))}
+
+
+
-
-
+
+
{tabId === "" ? (
No Active Tab
) : (
-
+
)}
diff --git a/frontend/builder/builder-workspace.tsx b/frontend/builder/builder-workspace.tsx
index 94a06fad83..aab14ff458 100644
--- a/frontend/builder/builder-workspace.tsx
+++ b/frontend/builder/builder-workspace.tsx
@@ -97,7 +97,7 @@ const BuilderWorkspace = memo(() => {
-
+
diff --git a/frontend/preview/mock/mockfilesystem.ts b/frontend/preview/mock/mockfilesystem.ts
new file mode 100644
index 0000000000..6652bbb3fe
--- /dev/null
+++ b/frontend/preview/mock/mockfilesystem.ts
@@ -0,0 +1,543 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { arrayToBase64 } from "@/util/util";
+
+const MockHomePath = "/Users/mike";
+const MockDirMimeType = "directory";
+const MockDirMode = 0o040755;
+const MockFileMode = 0o100644;
+const MockDirectoryChunkSize = 128;
+const MockFileChunkSize = 64 * 1024;
+const MockBaseModTime = Date.parse("2026-03-10T09:00:00.000Z");
+const TinyPngBytes = Uint8Array.from([
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
+ 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x04, 0x00, 0x00, 0x00, 0xb5, 0x1c, 0x0c,
+ 0x02, 0x00, 0x00, 0x00, 0x0b, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xfc, 0xff, 0x1f, 0x00,
+ 0x03, 0x03, 0x01, 0xff, 0xa5, 0xf8, 0x8f, 0xb1, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44,
+ 0xae, 0x42, 0x60, 0x82,
+]);
+const TinyJpegBytes = Uint8Array.from([
+ 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01,
+ 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03,
+ 0x03, 0x03, 0x03, 0x04, 0x03, 0x03, 0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0a, 0x07,
+ 0x07, 0x06, 0x08, 0x0c, 0x0a, 0x0c, 0x0c, 0x0b, 0x0a, 0x0b, 0x0b, 0x0d, 0x0e, 0x12, 0x10, 0x0d,
+ 0x0e, 0x11, 0x0e, 0x0b, 0x0b, 0x10, 0x16, 0x10, 0x11, 0x13, 0x14, 0x15, 0x15, 0x15, 0x0c, 0x0f,
+ 0x17, 0x18, 0x16, 0x14, 0x18, 0x12, 0x14, 0x15, 0x14, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01,
+ 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0xff, 0xc4, 0x00, 0x14,
+ 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0xbf, 0xff, 0xd9,
+]);
+
+type MockFsEntry = {
+ path: string;
+ dir: string;
+ name: string;
+ isdir: boolean;
+ mimetype: string;
+ modtime: number;
+ mode: number;
+ size: number;
+ readonly?: boolean;
+ supportsmkdir?: boolean;
+ content?: Uint8Array;
+};
+
+type MockFsEntryInput = {
+ path: string;
+ isdir?: boolean;
+ mimetype?: string;
+ readonly?: boolean;
+ content?: string | Uint8Array;
+};
+
+export type MockFilesystem = {
+ homePath: string;
+ fileCount: number;
+ directoryCount: number;
+ entryCount: number;
+ fileInfo: (data: FileData) => Promise;
+ fileRead: (data: FileData) => Promise;
+ fileList: (data: FileListData) => Promise;
+ fileJoin: (paths: string[]) => Promise;
+ fileReadStream: (data: FileData) => AsyncGenerator;
+ fileListStream: (data: FileListData) => AsyncGenerator;
+};
+
+function normalizeMockPath(path: string, basePath = MockHomePath): string {
+ if (path == null || path === "") {
+ return basePath;
+ }
+ if (path.startsWith("wsh://")) {
+ const url = new URL(path);
+ path = url.pathname.replace(/^\/+/, "/");
+ }
+ if (path === "~") {
+ path = MockHomePath;
+ } else if (path.startsWith("~/")) {
+ path = MockHomePath + path.slice(1);
+ }
+ if (!path.startsWith("/")) {
+ path = `${basePath}/${path}`;
+ }
+ const parts = path.split("/");
+ const resolvedParts: string[] = [];
+ for (const part of parts) {
+ if (!part || part === ".") {
+ continue;
+ }
+ if (part === "..") {
+ resolvedParts.pop();
+ continue;
+ }
+ resolvedParts.push(part);
+ }
+ const resolvedPath = "/" + resolvedParts.join("/");
+ return resolvedPath === "" ? "/" : resolvedPath;
+}
+
+function getDirName(path: string): string {
+ if (path === "/") {
+ return "/";
+ }
+ const idx = path.lastIndexOf("/");
+ if (idx <= 0) {
+ return "/";
+ }
+ return path.slice(0, idx);
+}
+
+function getBaseName(path: string): string {
+ if (path === "/") {
+ return "/";
+ }
+ const idx = path.lastIndexOf("/");
+ return idx < 0 ? path : path.slice(idx + 1);
+}
+
+function getMimeType(path: string, isdir: boolean): string {
+ if (isdir) {
+ return MockDirMimeType;
+ }
+ if (path.endsWith(".md")) {
+ return "text/markdown";
+ }
+ if (path.endsWith(".json")) {
+ return "application/json";
+ }
+ if (path.endsWith(".ts")) {
+ return "text/typescript";
+ }
+ if (path.endsWith(".tsx")) {
+ return "text/tsx";
+ }
+ if (path.endsWith(".js")) {
+ return "text/javascript";
+ }
+ if (path.endsWith(".txt") || path.endsWith(".log") || path.endsWith(".bashrc") || path.endsWith(".zprofile")) {
+ return "text/plain";
+ }
+ if (path.endsWith(".png")) {
+ return "image/png";
+ }
+ if (path.endsWith(".jpg") || path.endsWith(".jpeg")) {
+ return "image/jpeg";
+ }
+ if (path.endsWith(".pdf")) {
+ return "application/pdf";
+ }
+ if (path.endsWith(".zip")) {
+ return "application/zip";
+ }
+ if (path.endsWith(".dmg")) {
+ return "application/x-apple-diskimage";
+ }
+ if (path.endsWith(".svg")) {
+ return "image/svg+xml";
+ }
+ if (path.endsWith(".yaml") || path.endsWith(".yml")) {
+ return "application/yaml";
+ }
+ return "application/octet-stream";
+}
+
+function makeContentBytes(content: string | Uint8Array): Uint8Array {
+ if (content instanceof Uint8Array) {
+ return content;
+ }
+ return new TextEncoder().encode(content);
+}
+
+function makeMockFsInput(path: string, content?: string | Uint8Array, mimetype?: string): MockFsEntryInput {
+ return { path, content, mimetype };
+}
+
+function createMockFilesystemEntries(): MockFsEntryInput[] {
+ const entries: MockFsEntryInput[] = [
+ { path: "/", isdir: true },
+ { path: "/Users", isdir: true },
+ { path: MockHomePath, isdir: true },
+ { path: `${MockHomePath}/Desktop`, isdir: true },
+ { path: `${MockHomePath}/Documents`, isdir: true },
+ { path: `${MockHomePath}/Downloads`, isdir: true },
+ { path: `${MockHomePath}/Pictures`, isdir: true },
+ { path: `${MockHomePath}/Projects`, isdir: true },
+ { path: `${MockHomePath}/waveterm`, isdir: true },
+ { path: `${MockHomePath}/waveterm/docs`, isdir: true },
+ { path: `${MockHomePath}/waveterm/images`, isdir: true },
+ { path: `${MockHomePath}/.config`, isdir: true },
+ makeMockFsInput(
+ `${MockHomePath}/.bashrc`,
+ `export PATH="$HOME/bin:$PATH"\nalias gs="git status -sb"\nexport WAVETERM_THEME="midnight"\n`,
+ "text/plain"
+ ),
+ makeMockFsInput(`${MockHomePath}/.gitconfig`),
+ makeMockFsInput(`${MockHomePath}/.zprofile`),
+ makeMockFsInput(`${MockHomePath}/todo.txt`),
+ makeMockFsInput(`${MockHomePath}/notes.txt`),
+ makeMockFsInput(`${MockHomePath}/shell-aliases`),
+ makeMockFsInput(`${MockHomePath}/archive.log`),
+ makeMockFsInput(`${MockHomePath}/session.txt`),
+ makeMockFsInput(`${MockHomePath}/Desktop/launch-plan.md`),
+ makeMockFsInput(`${MockHomePath}/Desktop/coffee.txt`),
+ makeMockFsInput(`${MockHomePath}/Desktop/daily-standup.txt`),
+ makeMockFsInput(`${MockHomePath}/Desktop/snippets.txt`),
+ makeMockFsInput(`${MockHomePath}/Desktop/terminal-theme.png`),
+ makeMockFsInput(`${MockHomePath}/Desktop/macos-shortcuts.txt`),
+ makeMockFsInput(`${MockHomePath}/Desktop/bug-scrub.txt`),
+ makeMockFsInput(`${MockHomePath}/Desktop/parking-receipt.pdf`),
+ makeMockFsInput(`${MockHomePath}/Desktop/demo-script.md`),
+ makeMockFsInput(`${MockHomePath}/Desktop/roadmap-draft.txt`),
+ makeMockFsInput(`${MockHomePath}/Desktop/pairing-notes.txt`),
+ makeMockFsInput(`${MockHomePath}/Desktop/wave-window.jpg`),
+ makeMockFsInput(
+ `${MockHomePath}/Documents/meeting-notes.md`,
+ `# File Preview Notes\n\n- Build a richer preview mock environment.\n- Add a fake filesystem rooted at \`${MockHomePath}\`.\n- Make markdown previews resolve relative assets.\n`,
+ "text/markdown"
+ ),
+ makeMockFsInput(`${MockHomePath}/Documents/architecture-overview.md`),
+ makeMockFsInput(`${MockHomePath}/Documents/release-checklist.md`),
+ makeMockFsInput(`${MockHomePath}/Documents/ideas.txt`),
+ makeMockFsInput(`${MockHomePath}/Documents/customer-feedback.txt`),
+ makeMockFsInput(`${MockHomePath}/Documents/cli-ux-notes.txt`),
+ makeMockFsInput(`${MockHomePath}/Documents/migration-plan.md`),
+ makeMockFsInput(`${MockHomePath}/Documents/design-review.md`),
+ makeMockFsInput(`${MockHomePath}/Documents/ops-runbook.md`),
+ makeMockFsInput(`${MockHomePath}/Documents/troubleshooting.txt`),
+ makeMockFsInput(`${MockHomePath}/Documents/preview-fixtures.txt`),
+ makeMockFsInput(`${MockHomePath}/Documents/backlog.txt`),
+ makeMockFsInput(`${MockHomePath}/Documents/feature-flags.yaml`),
+ makeMockFsInput(`${MockHomePath}/Documents/connections.csv`),
+ makeMockFsInput(`${MockHomePath}/Documents/ssh-hosts.txt`),
+ makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-01.md`),
+ makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-05.md`),
+ makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-09.md`),
+ makeMockFsInput(`${MockHomePath}/Downloads/waveterm-nightly.dmg`),
+ makeMockFsInput(`${MockHomePath}/Downloads/screenshot-pack.zip`),
+ makeMockFsInput(`${MockHomePath}/Downloads/cli-reference.pdf`),
+ makeMockFsInput(`${MockHomePath}/Downloads/ssh-cheatsheet.pdf`),
+ makeMockFsInput(`${MockHomePath}/Downloads/perf-trace.json`),
+ makeMockFsInput(`${MockHomePath}/Downloads/terminal-icons.zip`),
+ makeMockFsInput(`${MockHomePath}/Downloads/demo-data.csv`),
+ makeMockFsInput(`${MockHomePath}/Downloads/deploy-plan.txt`),
+ makeMockFsInput(`${MockHomePath}/Downloads/customer-audio.m4a`),
+ makeMockFsInput(`${MockHomePath}/Downloads/mock-shell-history.txt`),
+ makeMockFsInput(`${MockHomePath}/Downloads/design-assets.zip`),
+ makeMockFsInput(`${MockHomePath}/Downloads/old-preview-build.dmg`),
+ makeMockFsInput(`${MockHomePath}/Downloads/testing-samples.tar`),
+ makeMockFsInput(`${MockHomePath}/Downloads/workflow-failure.log`),
+ makeMockFsInput(`${MockHomePath}/Downloads/team-photo.jpg`),
+ makeMockFsInput(`${MockHomePath}/Downloads/preview-recording.mov`),
+ makeMockFsInput(`${MockHomePath}/Downloads/standup-notes.txt`),
+ makeMockFsInput(`${MockHomePath}/Downloads/metadata.json`),
+ makeMockFsInput(`${MockHomePath}/Pictures/beach-sunrise.png`, TinyPngBytes, "image/png"),
+ makeMockFsInput(`${MockHomePath}/Pictures/terminal-screenshot.jpg`, TinyJpegBytes, "image/jpeg"),
+ makeMockFsInput(`${MockHomePath}/Pictures/diagram.png`),
+ makeMockFsInput(`${MockHomePath}/Pictures/launch-party.jpg`),
+ makeMockFsInput(`${MockHomePath}/Pictures/icon-sketch.png`),
+ makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-01.png`),
+ makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-02.png`),
+ makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-03.png`),
+ makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-04.png`),
+ makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-05.png`),
+ makeMockFsInput(`${MockHomePath}/Pictures/product-shot-01.jpg`),
+ makeMockFsInput(`${MockHomePath}/Pictures/product-shot-02.jpg`),
+ makeMockFsInput(`${MockHomePath}/Pictures/product-shot-03.jpg`),
+ makeMockFsInput(`${MockHomePath}/Pictures/product-shot-04.jpg`),
+ makeMockFsInput(`${MockHomePath}/Pictures/product-shot-05.jpg`),
+ makeMockFsInput(`${MockHomePath}/Pictures/ui-concept.png`),
+ makeMockFsInput(`${MockHomePath}/Projects/local.env`),
+ makeMockFsInput(`${MockHomePath}/Projects/db-migration.sql`),
+ makeMockFsInput(`${MockHomePath}/Projects/prompt-lab.txt`),
+ makeMockFsInput(`${MockHomePath}/Projects/ui-spikes.tsx`),
+ makeMockFsInput(`${MockHomePath}/Projects/file-browser.tsx`),
+ makeMockFsInput(`${MockHomePath}/Projects/mock-data.json`),
+ makeMockFsInput(`${MockHomePath}/Projects/preview-api.ts`),
+ makeMockFsInput(`${MockHomePath}/Projects/bug-181.txt`),
+ makeMockFsInput(
+ `${MockHomePath}/waveterm/README.md`,
+ `# Mock WaveTerm Repo\n\nThis fake repo exists only in the preview environment.\nIt gives file previews something realistic to browse.\n`,
+ "text/markdown"
+ ),
+ makeMockFsInput(`${MockHomePath}/waveterm/package.json`),
+ makeMockFsInput(`${MockHomePath}/waveterm/tsconfig.json`),
+ makeMockFsInput(`${MockHomePath}/waveterm/Taskfile.yml`),
+ makeMockFsInput(`${MockHomePath}/waveterm/preview-model.tsx`),
+ makeMockFsInput(`${MockHomePath}/waveterm/mockwaveenv.ts`),
+ makeMockFsInput(`${MockHomePath}/waveterm/vite.config.ts`),
+ makeMockFsInput(`${MockHomePath}/waveterm/CHANGELOG.md`),
+ makeMockFsInput(
+ `${MockHomePath}/waveterm/docs/preview-notes.md`,
+ `# Preview Mocking\n\nUse the preview server to iterate on file previews without Electron.\nRelative markdown assets should resolve through \`FileJoinCommand\`.\n`,
+ "text/markdown"
+ ),
+ makeMockFsInput(`${MockHomePath}/waveterm/docs/filesystem-rpc.md`),
+ makeMockFsInput(`${MockHomePath}/waveterm/docs/test-plan.md`),
+ makeMockFsInput(`${MockHomePath}/waveterm/docs/connections.md`),
+ makeMockFsInput(`${MockHomePath}/waveterm/docs/preview-gallery.md`),
+ makeMockFsInput(`${MockHomePath}/waveterm/docs/release-notes.md`),
+ makeMockFsInput(`${MockHomePath}/waveterm/images/wave-logo.png`, TinyPngBytes, "image/png"),
+ makeMockFsInput(`${MockHomePath}/waveterm/images/hero.png`),
+ makeMockFsInput(`${MockHomePath}/waveterm/images/avatar.jpg`),
+ makeMockFsInput(`${MockHomePath}/waveterm/images/icon-16.png`),
+ makeMockFsInput(`${MockHomePath}/waveterm/images/icon-32.png`),
+ makeMockFsInput(`${MockHomePath}/waveterm/images/splash.jpg`),
+ makeMockFsInput(
+ `${MockHomePath}/.config/settings.json`,
+ JSON.stringify(
+ {
+ "app:theme": "wave-dark",
+ "preview:lastpath": `${MockHomePath}/Documents/meeting-notes.md`,
+ "window:magnifiedblockopacity": 0.92,
+ },
+ null,
+ 2
+ ),
+ "application/json"
+ ),
+ makeMockFsInput(`${MockHomePath}/.config/preview-cache.json`),
+ makeMockFsInput(`${MockHomePath}/.config/recent-workspaces.json`),
+ makeMockFsInput(`${MockHomePath}/.config/telemetry.log`),
+ ];
+ return entries;
+}
+
+function buildEntries(): Map {
+ const inputs = createMockFilesystemEntries();
+ const entries = new Map();
+ const ensureDir = (path: string) => {
+ const normalizedPath = normalizeMockPath(path, "/");
+ if (entries.has(normalizedPath)) {
+ return;
+ }
+ const dir = getDirName(normalizedPath);
+ if (normalizedPath !== "/") {
+ ensureDir(dir);
+ }
+ entries.set(normalizedPath, {
+ path: normalizedPath,
+ dir: normalizedPath === "/" ? "/" : dir,
+ name: normalizedPath === "/" ? "/" : getBaseName(normalizedPath),
+ isdir: true,
+ mimetype: MockDirMimeType,
+ modtime: MockBaseModTime + entries.size * 60000,
+ mode: MockDirMode,
+ size: 0,
+ supportsmkdir: true,
+ });
+ };
+ for (const input of inputs) {
+ const normalizedPath = normalizeMockPath(input.path, "/");
+ const isdir = input.isdir ?? false;
+ const dir = getDirName(normalizedPath);
+ if (normalizedPath !== "/") {
+ ensureDir(dir);
+ }
+ const content = input.content == null ? undefined : makeContentBytes(input.content);
+ entries.set(normalizedPath, {
+ path: normalizedPath,
+ dir: normalizedPath === "/" ? "/" : dir,
+ name: normalizedPath === "/" ? "/" : getBaseName(normalizedPath),
+ isdir,
+ mimetype: input.mimetype ?? getMimeType(normalizedPath, isdir),
+ modtime: MockBaseModTime + entries.size * 60000,
+ mode: isdir ? MockDirMode : MockFileMode,
+ size: content?.byteLength ?? 0,
+ readonly: input.readonly,
+ supportsmkdir: isdir,
+ content,
+ });
+ }
+ return entries;
+}
+
+function toFileInfo(entry: MockFsEntry): FileInfo {
+ return {
+ path: entry.path,
+ dir: entry.dir,
+ name: entry.name,
+ size: entry.size,
+ mode: entry.mode,
+ modtime: entry.modtime,
+ isdir: entry.isdir,
+ supportsmkdir: entry.supportsmkdir,
+ mimetype: entry.mimetype,
+ readonly: entry.readonly,
+ };
+}
+
+function makeNotFoundInfo(path: string): FileInfo {
+ const normalizedPath = normalizeMockPath(path);
+ return {
+ path: normalizedPath,
+ dir: getDirName(normalizedPath),
+ name: getBaseName(normalizedPath),
+ notfound: true,
+ supportsmkdir: true,
+ };
+}
+
+function sliceEntries(entries: FileInfo[], opts?: FileListOpts): FileInfo[] {
+ let filteredEntries = entries;
+ if (!opts?.all) {
+ filteredEntries = filteredEntries.filter((entry) => entry.name != null && !entry.name.startsWith("."));
+ }
+ const offset = Math.max(opts?.offset ?? 0, 0);
+ const end = opts?.limit != null && opts.limit >= 0 ? offset + opts.limit : undefined;
+ return filteredEntries.slice(offset, end);
+}
+
+function joinPaths(paths: string[]): string {
+ if (paths.length === 0) {
+ return MockHomePath;
+ }
+ let currentPath = normalizeMockPath(paths[0]);
+ for (const part of paths.slice(1)) {
+ currentPath = normalizeMockPath(part, currentPath);
+ }
+ return currentPath;
+}
+
+function getReadRange(data: FileData, size: number): { offset: number; end: number } {
+ const offset = Math.max(data?.at?.offset ?? 0, 0);
+ const end = data?.at?.size != null ? Math.min(offset + data.at.size, size) : size;
+ return { offset, end: Math.max(offset, end) };
+}
+
+export function makeMockFilesystem(): MockFilesystem {
+ const entries = buildEntries();
+ const childrenByDir = new Map();
+ for (const entry of entries.values()) {
+ if (entry.path === "/") {
+ continue;
+ }
+ if (!childrenByDir.has(entry.dir)) {
+ childrenByDir.set(entry.dir, []);
+ }
+ childrenByDir.get(entry.dir).push(entry);
+ }
+ for (const childEntries of childrenByDir.values()) {
+ childEntries.sort((a, b) => {
+ if (a.isdir !== b.isdir) {
+ return a.isdir ? -1 : 1;
+ }
+ return a.name.localeCompare(b.name);
+ });
+ }
+ const getEntry = (path: string): MockFsEntry => {
+ return entries.get(normalizeMockPath(path));
+ };
+ const fileInfo = async (data: FileData): Promise => {
+ const entry = getEntry(data?.info?.path ?? MockHomePath);
+ if (!entry) {
+ return makeNotFoundInfo(data?.info?.path ?? MockHomePath);
+ }
+ return toFileInfo(entry);
+ };
+ const fileRead = async (data: FileData): Promise => {
+ const info = await fileInfo(data);
+ if (info.notfound) {
+ return { info };
+ }
+ const entry = getEntry(info.path);
+ if (entry.isdir) {
+ const childEntries = (childrenByDir.get(entry.path) ?? []).map((child) => toFileInfo(child));
+ return { info, entries: childEntries };
+ }
+ if (entry.content == null || entry.content.byteLength === 0) {
+ return { info };
+ }
+ const { offset, end } = getReadRange(data, entry.content.byteLength);
+ return {
+ info,
+ data64: arrayToBase64(entry.content.slice(offset, end)),
+ at: { offset, size: end - offset },
+ };
+ };
+ const fileList = async (data: FileListData): Promise => {
+ const dirPath = normalizeMockPath(data?.path ?? MockHomePath);
+ const entry = getEntry(dirPath);
+ if (entry == null || !entry.isdir) {
+ return [];
+ }
+ const dirEntries = (childrenByDir.get(dirPath) ?? []).map((child) => toFileInfo(child));
+ return sliceEntries(dirEntries, data?.opts);
+ };
+ const fileJoin = async (paths: string[]): Promise => {
+ const path = paths.length === 1 ? normalizeMockPath(paths[0]) : joinPaths(paths);
+ const entry = getEntry(path);
+ if (!entry) {
+ return makeNotFoundInfo(path);
+ }
+ return toFileInfo(entry);
+ };
+ const fileReadStream = async function* (data: FileData): AsyncGenerator {
+ const info = await fileInfo(data);
+ yield { info };
+ if (info.notfound) {
+ return;
+ }
+ const entry = getEntry(info.path);
+ if (entry.isdir) {
+ const dirEntries = (childrenByDir.get(entry.path) ?? []).map((child) => toFileInfo(child));
+ for (let idx = 0; idx < dirEntries.length; idx += MockDirectoryChunkSize) {
+ yield { entries: dirEntries.slice(idx, idx + MockDirectoryChunkSize) };
+ }
+ return;
+ }
+ if (entry.content == null || entry.content.byteLength === 0) {
+ return;
+ }
+ const { offset, end } = getReadRange(data, entry.content.byteLength);
+ for (let currentOffset = offset; currentOffset < end; currentOffset += MockFileChunkSize) {
+ const chunkEnd = Math.min(currentOffset + MockFileChunkSize, end);
+ yield {
+ data64: arrayToBase64(entry.content.slice(currentOffset, chunkEnd)),
+ at: { offset: currentOffset, size: chunkEnd - currentOffset },
+ };
+ }
+ };
+ const fileListStream = async function* (data: FileListData): AsyncGenerator {
+ const fileInfos = await fileList(data);
+ for (let idx = 0; idx < fileInfos.length; idx += MockDirectoryChunkSize) {
+ yield { fileinfo: fileInfos.slice(idx, idx + MockDirectoryChunkSize) };
+ }
+ };
+ const fileCount = Array.from(entries.values()).filter((entry) => !entry.isdir).length;
+ const directoryCount = Array.from(entries.values()).filter((entry) => entry.isdir).length;
+ return {
+ homePath: MockHomePath,
+ fileCount,
+ directoryCount,
+ entryCount: entries.size,
+ fileInfo,
+ fileRead,
+ fileList,
+ fileJoin,
+ fileReadStream,
+ fileListStream,
+ };
+}
+
+export const DefaultMockFilesystem = makeMockFilesystem();
diff --git a/frontend/preview/mock/mockwaveenv.test.ts b/frontend/preview/mock/mockwaveenv.test.ts
index 953e8412d4..25aee22995 100644
--- a/frontend/preview/mock/mockwaveenv.test.ts
+++ b/frontend/preview/mock/mockwaveenv.test.ts
@@ -1,4 +1,6 @@
+import { base64ToArray, base64ToString } from "@/util/util";
import { describe, expect, it, vi } from "vitest";
+import { DefaultMockFilesystem } from "./mockfilesystem";
const { showPreviewContextMenu } = vi.hoisted(() => ({
showPreviewContextMenu: vi.fn(),
@@ -19,4 +21,76 @@ describe("makeMockWaveEnv", () => {
expect(showPreviewContextMenu).toHaveBeenCalledWith(menu, event);
});
+
+ it("provides a populated mock filesystem rooted at /Users/mike", () => {
+ expect(DefaultMockFilesystem.homePath).toBe("/Users/mike");
+ expect(DefaultMockFilesystem.fileCount).toBeGreaterThanOrEqual(100);
+ expect(DefaultMockFilesystem.directoryCount).toBeGreaterThanOrEqual(10);
+ });
+
+ it("implements file info, read, list, and join commands", async () => {
+ const { makeMockWaveEnv } = await import("./mockwaveenv");
+ const env = makeMockWaveEnv();
+
+ const bashrcInfo = await env.rpc.FileInfoCommand(null as any, {
+ info: { path: "wsh://local//Users/mike/.bashrc" },
+ });
+ expect(bashrcInfo.path).toBe("/Users/mike/.bashrc");
+ expect(bashrcInfo.mimetype).toBe("text/plain");
+
+ const bashrcData = await env.rpc.FileReadCommand(null as any, {
+ info: { path: "wsh://local//Users/mike/.bashrc" },
+ });
+ expect(base64ToString(bashrcData.data64)).toContain('alias gs="git status -sb"');
+
+ const visibleHomeEntries = await env.rpc.FileListCommand(null as any, {
+ path: "/Users/mike",
+ });
+ expect(visibleHomeEntries.some((entry) => entry.name === ".bashrc")).toBe(false);
+ expect(visibleHomeEntries.some((entry) => entry.name === "waveterm")).toBe(true);
+
+ const allHomeEntries = await env.rpc.FileListCommand(null as any, {
+ path: "/Users/mike",
+ opts: { all: true },
+ });
+ expect(allHomeEntries.some((entry) => entry.name === ".bashrc")).toBe(true);
+
+ const dirRead = await env.rpc.FileReadCommand(null as any, {
+ info: { path: "/Users/mike/waveterm" },
+ });
+ expect(dirRead.entries.some((entry) => entry.name === "docs" && entry.isdir)).toBe(true);
+
+ const joined = await env.rpc.FileJoinCommand(null as any, [
+ "wsh://local//Users/mike/Documents",
+ "../waveterm/docs",
+ "preview-notes.md",
+ ]);
+ expect(joined.path).toBe("/Users/mike/waveterm/docs/preview-notes.md");
+ expect(joined.mimetype).toBe("text/markdown");
+ });
+
+ it("implements file list and read stream commands", async () => {
+ const { makeMockWaveEnv } = await import("./mockwaveenv");
+ const env = makeMockWaveEnv();
+
+ const listPackets: CommandRemoteListEntriesRtnData[] = [];
+ for await (const packet of env.rpc.FileListStreamCommand(null as any, {
+ path: "/Users/mike",
+ opts: { all: true, limit: 4 },
+ })) {
+ listPackets.push(packet);
+ }
+ expect(listPackets).toHaveLength(1);
+ expect(listPackets[0].fileinfo).toHaveLength(4);
+
+ const readPackets: FileData[] = [];
+ for await (const packet of env.rpc.FileReadStreamCommand(null as any, {
+ info: { path: "/Users/mike/Pictures/beach-sunrise.png" },
+ })) {
+ readPackets.push(packet);
+ }
+ expect(readPackets[0].info?.path).toBe("/Users/mike/Pictures/beach-sunrise.png");
+ const imageBytes = base64ToArray(readPackets[1].data64);
+ expect(Array.from(imageBytes.slice(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]);
+ });
});
diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts
index aaccb0dd32..5e787610e5 100644
--- a/frontend/preview/mock/mockwaveenv.ts
+++ b/frontend/preview/mock/mockwaveenv.ts
@@ -10,6 +10,7 @@ import { WaveEnv } from "@/app/waveenv/waveenv";
import { PlatformMacOS, PlatformWindows } from "@/util/platformutil";
import { Atom, atom, PrimitiveAtom, useAtomValue } from "jotai";
import { DefaultFullConfig } from "./defaultconfig";
+import { DefaultMockFilesystem } from "./mockfilesystem";
import { showPreviewContextMenu } from "../preview-contextmenu";
import { previewElectronApi } from "./preview-electron-api";
@@ -20,6 +21,7 @@ import { previewElectronApi } from "./preview-electron-api";
// 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.SetConfigCommand -- merges settings into fullConfigAtom (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
//
@@ -32,7 +34,9 @@ import { previewElectronApi } from "./preview-electron-api";
// e.g. { "block": { "GetControllerStatus": (blockId) => myStatus } }
type RpcOverrides = {
- [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (...args: any[]) => Promise;
+ [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (
+ ...args: any[]
+ ) => Promise | AsyncGenerator;
};
type ServiceOverrides = {
@@ -173,21 +177,29 @@ function makeMockGlobalAtoms(
type MockWosFns = {
getWaveObjectAtom: (oref: string) => PrimitiveAtom;
mockSetWaveObj: (oref: string, obj: T) => void;
+ fullConfigAtom: PrimitiveAtom;
};
export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiType {
- const dispatchMap = new Map Promise>();
- dispatchMap.set("eventpublish", async (_client, data: WaveEvent) => {
+ const callDispatchMap = new Map Promise>();
+ const streamDispatchMap = new Map AsyncGenerator>();
+ const setCallHandler = (command: string, fn: (...args: any[]) => Promise) => {
+ callDispatchMap.set(command, fn);
+ };
+ const setStreamHandler = (command: string, fn: (...args: any[]) => AsyncGenerator) => {
+ streamDispatchMap.set(command, fn);
+ };
+ setCallHandler("eventpublish", async (_client, data: WaveEvent) => {
console.log("[mock eventpublish]", data);
handleWaveEvent(data);
return null;
});
- dispatchMap.set("getmeta", async (_client, data: CommandGetMetaData) => {
+ setCallHandler("getmeta", async (_client, data: CommandGetMetaData) => {
const objAtom = wos.getWaveObjectAtom(data.oref);
const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType };
return current?.meta ?? {};
});
- dispatchMap.set("setmeta", async (_client, data: CommandSetMetaData) => {
+ setCallHandler("setmeta", async (_client, data: CommandSetMetaData) => {
const objAtom = wos.getWaveObjectAtom(data.oref);
const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType };
const updatedMeta = { ...(current?.meta ?? {}) };
@@ -202,7 +214,7 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp
wos.mockSetWaveObj(data.oref, updated);
return null;
});
- dispatchMap.set("updatetabname", async (_client, data: { args: [string, string] }) => {
+ setCallHandler("updatetabname", async (_client, data: { args: [string, string] }) => {
const [tabId, newName] = data.args;
const tabORef = "tab:" + tabId;
const objAtom = wos.getWaveObjectAtom(tabORef);
@@ -211,7 +223,20 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp
wos.mockSetWaveObj(tabORef, updated);
return null;
});
- dispatchMap.set("updateworkspacetabids", async (_client, data: { args: [string, string[]] }) => {
+ setCallHandler("setconfig", async (_client, data: SettingsType) => {
+ const current = globalStore.get(wos.fullConfigAtom);
+ const updatedSettings = { ...(current?.settings ?? {}) };
+ for (const [key, value] of Object.entries(data)) {
+ if (value === null) {
+ delete (updatedSettings as any)[key];
+ } else {
+ (updatedSettings as any)[key] = value;
+ }
+ }
+ globalStore.set(wos.fullConfigAtom, { ...current, settings: updatedSettings as SettingsType });
+ return null;
+ });
+ setCallHandler("updateworkspacetabids", async (_client, data: { args: [string, string[]] }) => {
const [workspaceId, tabIds] = data.args;
const wsORef = "workspace:" + workspaceId;
const objAtom = wos.getWaveObjectAtom(wsORef);
@@ -220,16 +245,30 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp
wos.mockSetWaveObj(wsORef, updated);
return null;
});
+ setCallHandler("fileinfo", async (_client, data: FileData) => DefaultMockFilesystem.fileInfo(data));
+ setCallHandler("fileread", async (_client, data: FileData) => DefaultMockFilesystem.fileRead(data));
+ setCallHandler("filelist", async (_client, data: FileListData) => DefaultMockFilesystem.fileList(data));
+ setCallHandler("filejoin", async (_client, data: string[]) => DefaultMockFilesystem.fileJoin(data));
+ setStreamHandler("filereadstream", async function* (_client, data: FileData) {
+ yield* DefaultMockFilesystem.fileReadStream(data);
+ });
+ setStreamHandler("fileliststream", async function* (_client, data: FileListData) {
+ yield* DefaultMockFilesystem.fileListStream(data);
+ });
if (overrides) {
for (const key of Object.keys(overrides) as (keyof RpcOverrides)[]) {
const cmdName = key.slice(0, -"Command".length).toLowerCase();
- dispatchMap.set(cmdName, overrides[key] as (...args: any[]) => Promise);
+ if (cmdName === "filereadstream" || cmdName === "fileliststream") {
+ setStreamHandler(cmdName, overrides[key] as (...args: any[]) => AsyncGenerator);
+ } else {
+ setCallHandler(cmdName, overrides[key] as (...args: any[]) => Promise);
+ }
}
}
const rpc = new RpcApiType();
rpc.setMockRpcClient({
mockWshRpcCall(_client, command, data, _opts) {
- const fn = dispatchMap.get(command);
+ const fn = callDispatchMap.get(command);
if (fn) {
return fn(_client, data, _opts);
}
@@ -237,9 +276,14 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp
return Promise.resolve(null);
},
async *mockWshRpcStream(_client, command, data, _opts) {
- const fn = dispatchMap.get(command);
- if (fn) {
- yield await fn(_client, data, _opts);
+ const streamFn = streamDispatchMap.get(command);
+ if (streamFn) {
+ yield* streamFn(_client, data, _opts);
+ return;
+ }
+ const callFn = callDispatchMap.get(command);
+ if (callFn) {
+ yield await callFn(_client, data, _opts);
return;
}
console.log("[mock rpc stream]", command, data);
@@ -280,6 +324,7 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv {
});
const mockWosFns: MockWosFns = {
getWaveObjectAtom,
+ fullConfigAtom: atoms.fullConfigAtom,
mockSetWaveObj: (oref: string, obj: T) => {
if (!waveObjectValueAtomCache.has(oref)) {
waveObjectValueAtomCache.set(oref, atom(null as WaveObj));
diff --git a/frontend/preview/mock/tabbar-mock.tsx b/frontend/preview/mock/tabbar-mock.tsx
new file mode 100644
index 0000000000..c4a811f6e4
--- /dev/null
+++ b/frontend/preview/mock/tabbar-mock.tsx
@@ -0,0 +1,173 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { globalStore } from "@/app/store/jotaiStore";
+import { useWaveEnv, WaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv";
+import { applyMockEnvOverrides, MockWaveEnv } from "@/preview/mock/mockwaveenv";
+import { PlatformMacOS } from "@/util/platformutil";
+import { atom } from "jotai";
+import React, { useMemo, useRef } from "react";
+
+type PreviewTabEntry = {
+ tabId: string;
+ tabName: string;
+ badges?: Badge[] | null;
+ flagColor?: string | null;
+};
+
+function badgeBlockId(tabId: string, badgeId: string): string {
+ return `${tabId}-badge-${badgeId}`;
+}
+
+function makeTabWaveObj(tab: PreviewTabEntry): Tab {
+ const blockids = (tab.badges ?? []).map((b) => badgeBlockId(tab.tabId, b.badgeid));
+ return {
+ otype: "tab",
+ oid: tab.tabId,
+ version: 1,
+ name: tab.tabName,
+ blockids,
+ meta: tab.flagColor ? { "tab:flagcolor": tab.flagColor } : {},
+ } as Tab;
+}
+
+function makeMockBadgeEvents(): BadgeEvent[] {
+ const events: BadgeEvent[] = [];
+ for (const tab of TabBarMockTabs) {
+ for (const badge of tab.badges ?? []) {
+ events.push({ oref: `block:${badgeBlockId(tab.tabId, badge.badgeid)}`, badge });
+ }
+ }
+ return events;
+}
+
+export const TabBarMockWorkspaceId = "preview-workspace-1";
+
+export const TabBarMockTabs: 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 makeMockWorkspace(tabIds: string[]): Workspace {
+ return {
+ otype: "workspace",
+ oid: TabBarMockWorkspaceId,
+ version: 1,
+ name: "Preview Workspace",
+ tabids: tabIds,
+ activetabid: tabIds[1] ?? tabIds[0] ?? "",
+ meta: {},
+ } as Workspace;
+}
+
+export function makeTabBarMockEnv(
+ baseEnv: WaveEnv,
+ envRef: React.RefObject,
+ platform: NodeJS.Platform
+): MockWaveEnv {
+ const initialTabIds = TabBarMockTabs.map((t) => t.tabId);
+ const mockWaveObjs: Record = {
+ [`workspace:${TabBarMockWorkspaceId}`]: makeMockWorkspace(initialTabIds),
+ };
+ for (const tab of TabBarMockTabs) {
+ mockWaveObjs[`tab:${tab.tabId}`] = makeTabWaveObj(tab);
+ }
+ const env = applyMockEnvOverrides(baseEnv, {
+ tabId: TabBarMockTabs[1].tabId,
+ platform,
+ mockWaveObjs,
+ atoms: {
+ workspaceId: atom(TabBarMockWorkspaceId),
+ staticTabId: atom(TabBarMockTabs[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:${TabBarMockWorkspaceId}`));
+ e.mockSetWaveObj(`workspace:${TabBarMockWorkspaceId}`, {
+ ...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:${TabBarMockWorkspaceId}`));
+ const newTabIds = (ws.tabids ?? []).filter((id) => id !== tabId);
+ if (newTabIds.length === 0) {
+ return Promise.resolve(false);
+ }
+ e.mockSetWaveObj(`workspace:${TabBarMockWorkspaceId}`, { ...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;
+}
+
+type TabBarMockEnvProviderProps = {
+ children: React.ReactNode;
+};
+
+export function TabBarMockEnvProvider({ children }: TabBarMockEnvProviderProps) {
+ const baseEnv = useWaveEnv();
+ const envRef = useRef(null);
+ const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, PlatformMacOS), []);
+ return {children} ;
+}
+TabBarMockEnvProvider.displayName = "TabBarMockEnvProvider";
diff --git a/frontend/preview/previews/tabbar.preview.tsx b/frontend/preview/previews/tabbar.preview.tsx
index 104ef4f8a6..f2ba2234b7 100644
--- a/frontend/preview/previews/tabbar.preview.tsx
+++ b/frontend/preview/previews/tabbar.preview.tsx
@@ -2,171 +2,26 @@
// SPDX-License-Identifier: Apache-2.0
import { loadBadges, LoadBadgesEnv } from "@/app/store/badge";
-import { globalStore } from "@/app/store/jotaiStore";
import { TabBar } from "@/app/tab/tabbar";
import { TabBarEnv } from "@/app/tab/tabbarenv";
import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv";
-import { applyMockEnvOverrides, MockWaveEnv } from "@/preview/mock/mockwaveenv";
+import { makeTabBarMockEnv, TabBarMockWorkspaceId } from "@/preview/mock/tabbar-mock";
+import { MockWaveEnv } from "@/preview/mock/mockwaveenv";
import { PlatformLinux, PlatformMacOS, PlatformWindows } from "@/util/platformutil";
-import { atom, useAtom, useAtomValue } from "jotai";
+import { useAtom, useAtomValue } from "jotai";
import { CSSProperties, useEffect, useMemo, useRef, useState } from "react";
-type PreviewTabEntry = {
- tabId: string;
- tabName: string;
- badges?: Badge[] | null;
- flagColor?: string | null;
-};
-
-function badgeBlockId(tabId: string, badgeId: string): string {
- return `${tabId}-badge-${badgeId}`;
-}
-
-function makeTabWaveObj(tab: PreviewTabEntry): Tab {
- const blockids = (tab.badges ?? []).map((b) => badgeBlockId(tab.tabId, b.badgeid));
- return {
- otype: "tab",
- oid: tab.tabId,
- version: 1,
- name: tab.tabName,
- blockids,
- meta: tab.flagColor ? { "tab:flagcolor": tab.flagColor } : {},
- } as Tab;
-}
-
-function makeMockBadgeEvents(): BadgeEvent[] {
- const events: BadgeEvent[] = [];
- for (const tab of InitialTabs) {
- for (const badge of tab.badges ?? []) {
- events.push({ oref: `block:${badgeBlockId(tab.tabId, badge.badgeid)}`, badge });
- }
- }
- return events;
-}
-
-const MockWorkspaceId = "preview-workspace-1";
-const InitialTabs: PreviewTabEntry[] = [
- { tabId: "preview-tab-1", tabName: "Terminal" },
- {
- tabId: "preview-tab-2",
- tabName: "Build Logs",
- badges: [
- {
- badgeid: "01958000-0000-7000-0000-000000000001",
- icon: "triangle-exclamation",
- color: "#f59e0b",
- priority: 2,
- },
- ],
- },
- {
- tabId: "preview-tab-3",
- tabName: "Deploy",
- badges: [
- { badgeid: "01958000-0000-7000-0000-000000000002", icon: "circle-check", color: "#4ade80", priority: 3 },
- ],
- flagColor: "#429dff",
- },
- {
- tabId: "preview-tab-4",
- tabName: "A Very Long Tab Name To Show Truncation",
- badges: [
- { badgeid: "01958000-0000-7000-0000-000000000003", icon: "bell", color: "#f87171", priority: 2 },
- { badgeid: "01958000-0000-7000-0000-000000000004", icon: "circle-small", color: "#fbbf24", priority: 1 },
- ],
- },
- { tabId: "preview-tab-5", tabName: "Wave AI" },
- { tabId: "preview-tab-6", tabName: "Preview", flagColor: "#bf55ec" },
-];
-
const MockConfigErrors: ConfigError[] = [
{ file: "~/.waveterm/config.json", err: 'unknown preset "bg@aurora"' },
{ file: "~/.waveterm/settings.json", err: "invalid color for tab theme" },
];
-function makeMockWorkspace(tabIds: string[]): Workspace {
- return {
- otype: "workspace",
- oid: MockWorkspaceId,
- version: 1,
- name: "Preview Workspace",
- tabids: tabIds,
- activetabid: tabIds[1] ?? tabIds[0] ?? "",
- meta: {},
- } as Workspace;
-}
-
export function TabBarPreview() {
const baseEnv = useWaveEnv();
- const initialTabIds = InitialTabs.map((t) => t.tabId);
const envRef = useRef(null);
const [platform, setPlatform] = useState(PlatformMacOS);
- const tabEnv = useMemo(() => {
- 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,
- platform,
- mockWaveObjs,
- atoms: {
- workspaceId: atom(MockWorkspaceId),
- staticTabId: atom(InitialTabs[1].tabId),
- },
- rpc: {
- GetAllBadgesCommand: () => Promise.resolve(makeMockBadgeEvents()),
- },
- electron: {
- createTab: () => {
- const e = envRef.current;
- if (e == null) return;
- const newTabId = `preview-tab-${crypto.randomUUID()}`;
- e.mockSetWaveObj(`tab:${newTabId}`, {
- otype: "tab",
- oid: newTabId,
- version: 1,
- name: "New Tab",
- blockids: [],
- meta: {},
- } as Tab);
- const ws = globalStore.get(e.wos.getWaveObjectAtom(`workspace:${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;
- }, [platform]);
+ const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, platform), [platform]);
return (
@@ -190,7 +45,7 @@ function TabBarPreviewInner({ platform, setPlatform }: TabBarPreviewInnerProps)
const [zoomFactor, setZoomFactor] = useAtom(env.atoms.zoomFactorAtom);
const [fullConfig, setFullConfig] = useAtom(env.atoms.fullConfigAtom);
const [updaterStatus, setUpdaterStatus] = useAtom(env.atoms.updaterStatusAtom);
- const workspace = useAtomValue(env.wos.getWaveObjectAtom(`workspace:${MockWorkspaceId}`));
+ const workspace = useAtomValue(env.wos.getWaveObjectAtom(`workspace:${TabBarMockWorkspaceId}`));
useEffect(() => {
loadBadges(loadBadgesEnv);
diff --git a/frontend/preview/previews/vtabbar.preview.tsx b/frontend/preview/previews/vtabbar.preview.tsx
index c4739f593d..90b7907be2 100644
--- a/frontend/preview/previews/vtabbar.preview.tsx
+++ b/frontend/preview/previews/vtabbar.preview.tsx
@@ -1,80 +1,128 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
-import { VTabBar, VTabItem } from "@/app/tab/vtabbar";
-import { useState } from "react";
-
-const InitialTabs: VTabItem[] = [
- { id: "vtab-1", name: "Terminal" },
- {
- id: "vtab-2",
- name: "Build Logs",
- badges: [
- { badgeid: "01957000-0000-7000-0000-000000000001", icon: "bell", color: "#f59e0b", priority: 2 },
- { badgeid: "01957000-0000-7000-0000-000000000002", icon: "circle-small", color: "#4ade80", priority: 3 },
- ],
- },
- { id: "vtab-3", name: "Deploy", flagColor: "#429DFF" },
- { id: "vtab-4", name: "Wave AI" },
- {
- id: "vtab-5",
- name: "A Very Long Tab Name To Show Truncation",
- badges: [{ badgeid: "01957000-0000-7000-0000-000000000003", icon: "solid@terminal", color: "#fbbf24", priority: 3 }],
- flagColor: "#BF55EC",
- },
-];
+import { loadBadges, LoadBadgesEnv } from "@/app/store/badge";
+import { VTabBar } from "@/app/tab/vtabbar";
+import { VTabBarEnv } from "@/app/tab/vtabbarenv";
+import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv";
+import { MockWaveEnv } from "@/preview/mock/mockwaveenv";
+import { makeTabBarMockEnv, TabBarMockWorkspaceId } from "@/preview/mock/tabbar-mock";
+import { PlatformLinux, PlatformMacOS, PlatformWindows } from "@/util/platformutil";
+import { useAtom, useAtomValue } from "jotai";
+import { useEffect, useMemo, useRef, useState } from "react";
export function VTabBarPreview() {
- const [tabs, setTabs] = useState(InitialTabs);
- const [activeTabId, setActiveTabId] = useState(InitialTabs[0].id);
+ const baseEnv = useWaveEnv();
+ const envRef = useRef(null);
+ const [platform, setPlatform] = useState(PlatformMacOS);
+
+ const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, platform), [platform]);
+
+ return (
+
+
+
+ );
+}
+
+type VTabBarPreviewInnerProps = {
+ platform: NodeJS.Platform;
+ setPlatform: (platform: NodeJS.Platform) => void;
+};
+
+function VTabBarPreviewInner({ platform, setPlatform }: VTabBarPreviewInnerProps) {
+ const env = useWaveEnv();
+ const loadBadgesEnv = useWaveEnv();
+ const [hideAiButton, setHideAiButton] = useState(false);
+ const [isFullScreen, setIsFullScreen] = useAtom(env.atoms.isFullScreen);
+ const [fullConfig, setFullConfig] = useAtom(env.atoms.fullConfigAtom);
+ const [updaterStatus, setUpdaterStatus] = useAtom(env.atoms.updaterStatusAtom);
const [width, setWidth] = useState(220);
+ const workspace = useAtomValue(env.wos.getWaveObjectAtom(`workspace:${TabBarMockWorkspaceId}`));
- const handleCloseTab = (tabId: string) => {
- setTabs((prevTabs) => {
- const nextTabs = prevTabs.filter((tab) => tab.id !== tabId);
- if (activeTabId === tabId && nextTabs.length > 0) {
- setActiveTabId(nextTabs[0].id);
- }
- return nextTabs;
- });
- };
+ useEffect(() => {
+ loadBadges(loadBadgesEnv);
+ }, []);
+
+ useEffect(() => {
+ setFullConfig((prev) => ({
+ ...(prev ?? ({} as FullConfigType)),
+ settings: {
+ ...(prev?.settings ?? {}),
+ "app:hideaibutton": hideAiButton,
+ },
+ }));
+ }, [hideAiButton, setFullConfig]);
return (
-
-
-
Width: {width}px
-
setWidth(Number(event.target.value))}
- className="w-full cursor-pointer"
- />
-
- Drag tabs to reorder. Names, badges, and close buttons remain single-line.
-
+
+
+
+ Platform
+ setPlatform(event.target.value as NodeJS.Platform)}
+ className="rounded border border-border bg-background px-2 py-1 text-foreground cursor-pointer"
+ >
+ macOS
+ Windows
+ Linux
+
+
+
+ Updater banner
+ setUpdaterStatus(event.target.value as UpdaterStatus)}
+ className="rounded border border-border bg-background px-2 py-1 text-foreground"
+ >
+ Hidden
+ Update Available
+ Downloading
+ Installing
+ Error
+
+
+
+ Width: {width}px
+ setWidth(Math.max(100, Math.min(400, Number(event.target.value))))}
+ className="cursor-pointer"
+ />
+
+
+ setHideAiButton(event.target.checked)}
+ className="cursor-pointer"
+ />
+ Hide Wave AI button
+
+
+ setIsFullScreen(event.target.checked)}
+ className="cursor-pointer"
+ />
+ Full screen
+
-
-
{
- setTabs((prevTabs) =>
- prevTabs.map((tab) => (tab.id === tabId ? { ...tab, name: newName } : tab))
- );
- }}
- onReorderTabs={(tabIds) => {
- setTabs((prevTabs) => {
- const tabById = new Map(prevTabs.map((tab) => [tab.id, tab]));
- return tabIds.map((tabId) => tabById.get(tabId)).filter((tab) => tab != null);
- });
- }}
- />
+
+
+
+ {workspace != null && }
+
);
}
+VTabBarPreviewInner.displayName = "VTabBarPreviewInner";
diff --git a/frontend/preview/previews/web.preview.tsx b/frontend/preview/previews/web.preview.tsx
new file mode 100644
index 0000000000..56f9c215a4
--- /dev/null
+++ b/frontend/preview/previews/web.preview.tsx
@@ -0,0 +1,135 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { Block } from "@/app/block/block";
+import { globalStore } from "@/app/store/jotaiStore";
+import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model";
+import { mockObjectForPreview } from "@/app/store/wos";
+import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv";
+import type { NodeModel } from "@/layout/index";
+import { atom } from "jotai";
+import * as React from "react";
+import { applyMockEnvOverrides, MockWaveEnv } from "../mock/mockwaveenv";
+
+const PreviewWorkspaceId = "preview-web-workspace";
+const PreviewTabId = "preview-web-tab";
+const PreviewNodeId = "preview-web-node";
+const PreviewBlockId = "preview-web-block";
+const PreviewUrl = "https://waveterm.dev";
+
+function makeMockWorkspace(): Workspace {
+ return {
+ otype: "workspace",
+ oid: PreviewWorkspaceId,
+ version: 1,
+ name: "Preview Workspace",
+ tabids: [PreviewTabId],
+ activetabid: PreviewTabId,
+ meta: {},
+ } as Workspace;
+}
+
+function makeMockTab(): Tab {
+ return {
+ otype: "tab",
+ oid: PreviewTabId,
+ version: 1,
+ name: "Web Preview",
+ blockids: [PreviewBlockId],
+ meta: {},
+ } as Tab;
+}
+
+function makeMockBlock(): Block {
+ return {
+ otype: "block",
+ oid: PreviewBlockId,
+ version: 1,
+ meta: {
+ view: "web",
+ url: PreviewUrl,
+ },
+ } as Block;
+}
+
+const previewWaveObjs: Record
= {
+ [`workspace:${PreviewWorkspaceId}`]: makeMockWorkspace(),
+ [`tab:${PreviewTabId}`]: makeMockTab(),
+ [`block:${PreviewBlockId}`]: makeMockBlock(),
+};
+
+for (const [oref, obj] of Object.entries(previewWaveObjs)) {
+ mockObjectForPreview(oref, obj);
+}
+
+function makePreviewNodeModel(): NodeModel {
+ const isFocusedAtom = atom(true);
+ const isMagnifiedAtom = atom(false);
+
+ return {
+ additionalProps: atom({} as any),
+ innerRect: atom({ width: "1040px", height: "620px" }),
+ blockNum: atom(1),
+ numLeafs: atom(1),
+ nodeId: PreviewNodeId,
+ blockId: PreviewBlockId,
+ addEphemeralNodeToLayout: () => {},
+ animationTimeS: atom(0),
+ isResizing: atom(false),
+ isFocused: isFocusedAtom,
+ isMagnified: isMagnifiedAtom,
+ anyMagnified: atom(false),
+ isEphemeral: atom(false),
+ ready: atom(true),
+ disablePointerEvents: atom(false),
+ toggleMagnify: () => {
+ globalStore.set(isMagnifiedAtom, !globalStore.get(isMagnifiedAtom));
+ },
+ focusNode: () => {
+ globalStore.set(isFocusedAtom, true);
+ },
+ onClose: () => {},
+ dragHandleRef: { current: null },
+ displayContainerRef: { current: null },
+ };
+}
+
+function WebPreviewInner() {
+ const baseEnv = useWaveEnv();
+ const nodeModel = React.useMemo(() => makePreviewNodeModel(), []);
+
+ const env = React.useMemo(() => {
+ return applyMockEnvOverrides(baseEnv, {
+ tabId: PreviewTabId,
+ mockWaveObjs: previewWaveObjs,
+ atoms: {
+ workspaceId: atom(PreviewWorkspaceId),
+ staticTabId: atom(PreviewTabId),
+ },
+ settings: {
+ "web:defaultsearch": "https://www.google.com/search?q=%s",
+ },
+ });
+ }, [baseEnv]);
+
+ const tabModel = React.useMemo(() => getTabModelByTabId(PreviewTabId, env), [env]);
+
+ return (
+
+
+
+
full web block using preview mock fallback
+
+
+
+
+ );
+}
+
+export function WebPreview() {
+ return ;
+}
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts
index ddcb4a63e7..3dede74071 100644
--- a/frontend/types/gotypes.d.ts
+++ b/frontend/types/gotypes.d.ts
@@ -1121,6 +1121,7 @@ declare global {
"bg:blendmode"?: string;
"bg:bordercolor"?: string;
"bg:activebordercolor"?: string;
+ "layout:vtabbarwidth"?: number;
"waveai:panelopen"?: boolean;
"waveai:panelwidth"?: number;
"waveai:model"?: string;
@@ -1305,6 +1306,7 @@ declare global {
"app:disablectrlshiftarrows"?: boolean;
"app:disablectrlshiftdisplay"?: boolean;
"app:focusfollowscursor"?: string;
+ "app:tabbar"?: string;
"feature:waveappbuilder"?: boolean;
"ai:*"?: boolean;
"ai:preset"?: string;
diff --git a/frontend/util/platformutil.ts b/frontend/util/platformutil.ts
index ded79d3394..92fc240b0a 100644
--- a/frontend/util/platformutil.ts
+++ b/frontend/util/platformutil.ts
@@ -1,15 +1,28 @@
-// Copyright 2025, Command Line Inc.
+// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
export const PlatformMacOS = "darwin";
export const PlatformWindows = "win32";
export const PlatformLinux = "linux";
export let PLATFORM: NodeJS.Platform = PlatformMacOS;
+export let MacOSVersion: string = null;
export function setPlatform(platform: NodeJS.Platform) {
PLATFORM = platform;
}
+export function setMacOSVersion(version: string) {
+ MacOSVersion = version;
+}
+
+export function isMacOSTahoeOrLater(): boolean {
+ if (!isMacOS() || MacOSVersion == null) {
+ return false;
+ }
+ const major = parseInt(MacOSVersion.split(".")[0], 10);
+ return major >= 16;
+}
+
export function isMacOS(): boolean {
return PLATFORM == PlatformMacOS;
}
diff --git a/frontend/util/util.ts b/frontend/util/util.ts
index 2e5a3b5a13..8c2d330580 100644
--- a/frontend/util/util.ts
+++ b/frontend/util/util.ts
@@ -1,4 +1,4 @@
-// Copyright 2025, Command Line Inc.
+// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0s
import base64 from "base64-js";
@@ -108,7 +108,7 @@ function jsonDeepEqual(v1: any, v2: any): boolean {
if (keys1.length !== keys2.length) {
return false;
}
- for (let key of keys1) {
+ for (const key of keys1) {
if (!jsonDeepEqual(v1[key], v2[key])) {
return false;
}
diff --git a/frontend/wave.ts b/frontend/wave.ts
index a2ecb8a426..20ee2ba97a 100644
--- a/frontend/wave.ts
+++ b/frontend/wave.ts
@@ -32,6 +32,7 @@ import { activeTabIdAtom } from "@/store/tab-model";
import * as WOS from "@/store/wos";
import { loadFonts } from "@/util/fontutil";
import { setKeyUtilPlatform } from "@/util/keyutil";
+import { isMacOS, setMacOSVersion } from "@/util/platformutil";
import { createElement } from "react";
import { createRoot } from "react-dom/client";
@@ -159,13 +160,17 @@ async function initWave(initOpts: WaveInitOpts) {
const globalWS = initWshrpc(makeTabRouteId(initOpts.tabId));
(window as any).globalWS = globalWS;
(window as any).TabRpcClient = TabRpcClient;
- await loadConnStatus();
- await loadBadges();
- initGlobalWaveEventSubs(initOpts);
- subscribeToConnEvents();
// ensures client/window/workspace are loaded into the cache before rendering
try {
+ await loadConnStatus();
+ await loadBadges();
+ initGlobalWaveEventSubs(initOpts);
+ subscribeToConnEvents();
+ if (isMacOS()) {
+ const macOSVersion = await RpcApi.MacOSVersionCommand(TabRpcClient);
+ setMacOSVersion(macOSVersion);
+ }
const [_client, waveWindow, initialTab] = await Promise.all([
WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)),
WOS.loadAndPinWaveObject(WOS.makeORef("window", initOpts.windowId)),
diff --git a/go.mod b/go.mod
index 7615a351ee..bf6f92bab6 100644
--- a/go.mod
+++ b/go.mod
@@ -31,11 +31,11 @@ require (
github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b
github.com/wavetermdev/htmltoken v0.2.0
github.com/wavetermdev/waveterm/tsunami v0.12.3
- golang.org/x/crypto v0.48.0
+ golang.org/x/crypto v0.49.0
golang.org/x/mod v0.33.0
- golang.org/x/sync v0.19.0
- golang.org/x/sys v0.41.0
- golang.org/x/term v0.40.0
+ golang.org/x/sync v0.20.0
+ golang.org/x/sys v0.42.0
+ golang.org/x/term v0.41.0
google.golang.org/api v0.269.0
)
@@ -76,9 +76,9 @@ require (
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
- golang.org/x/net v0.50.0 // indirect
+ golang.org/x/net v0.51.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
- golang.org/x/text v0.34.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
diff --git a/go.sum b/go.sum
index 03a89cd1d2..7928668ed7 100644
--- a/go.sum
+++ b/go.sum
@@ -176,28 +176,28 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5w
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
-golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
-golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
-golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
+golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
+golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
-golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
-golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
-golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
+golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/package-lock.json b/package-lock.json
index 99c2a025b4..fbf3fa1c69 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "waveterm",
- "version": "0.14.2-beta.1",
+ "version": "0.14.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "waveterm",
- "version": "0.14.2-beta.1",
+ "version": "0.14.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -107,7 +107,7 @@
"@types/ws": "^8",
"@vitejs/plugin-react-swc": "4.2.3",
"@vitest/coverage-istanbul": "^3.0.9",
- "electron": "^40.4.1",
+ "electron": "^41.0.2",
"electron-builder": "^26.8",
"electron-vite": "^5.0",
"eslint": "^9.39",
@@ -14902,9 +14902,9 @@
}
},
"node_modules/electron": {
- "version": "40.4.1",
- "resolved": "https://registry.npmjs.org/electron/-/electron-40.4.1.tgz",
- "integrity": "sha512-N1ZXybQZL8kYemO8vAeh9nrk4mSvqlAO8xs0QCHkXIvRnuB/7VGwEehjvQbsU5/f4bmTKpG+2GQERe/zmKpudQ==",
+ "version": "41.0.2",
+ "resolved": "https://registry.npmjs.org/electron/-/electron-41.0.2.tgz",
+ "integrity": "sha512-raotm/aO8kOs1jD8SI8ssJ7EKciQOY295AOOprl1TxW7B0At8m5Ae7qNU1xdMxofiHMR8cNEGi9PKD3U+yT/mA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -30005,9 +30005,9 @@
}
},
"node_modules/tar": {
- "version": "7.5.10",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz",
- "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==",
+ "version": "7.5.11",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz",
+ "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
diff --git a/package.json b/package.json
index cd727bf39f..eeb75930ed 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"productName": "Wave",
"description": "Open-Source AI-Native Terminal Built for Seamless Workflows",
"license": "Apache-2.0",
- "version": "0.14.2-beta.1",
+ "version": "0.14.3",
"homepage": "https://waveterm.dev",
"build": {
"appId": "dev.commandline.waveterm"
@@ -48,7 +48,7 @@
"@types/ws": "^8",
"@vitejs/plugin-react-swc": "4.2.3",
"@vitest/coverage-istanbul": "^3.0.9",
- "electron": "^40.4.1",
+ "electron": "^41.0.2",
"electron-builder": "^26.8",
"electron-vite": "^5.0",
"eslint": "^9.39",
diff --git a/pkg/telemetry/telemetrydata/telemetrydata.go b/pkg/telemetry/telemetrydata/telemetrydata.go
index 222ebfbaed..7c85d7113a 100644
--- a/pkg/telemetry/telemetrydata/telemetrydata.go
+++ b/pkg/telemetry/telemetrydata/telemetrydata.go
@@ -20,16 +20,17 @@ var ValidEventNames = map[string]bool{
"app:display": true,
"app:counts": true,
- "action:magnify": true,
- "action:settabtheme": true,
- "action:runaicmd": true,
- "action:createtab": true,
- "action:createblock": true,
- "action:openwaveai": true,
- "action:other": true,
- "action:term": true,
- "action:termdurable": true,
- "action:link": true,
+ "action:magnify": true,
+ "action:settabtheme": true,
+ "action:runaicmd": true,
+ "action:createtab": true,
+ "action:createblock": true,
+ "action:openwaveai": true,
+ "action:openfileexplorer": true,
+ "action:other": true,
+ "action:term": true,
+ "action:termdurable": true,
+ "action:link": true,
"wsh:run": true,
diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go
index 8d92893afc..89c782c595 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 callBackendService(this.waveEnv, %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 {
diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go
index bf348023ac..7028d050be 100644
--- a/pkg/waveobj/metaconsts.go
+++ b/pkg/waveobj/metaconsts.go
@@ -98,6 +98,8 @@ const (
MetaKey_BgBorderColor = "bg:bordercolor"
MetaKey_BgActiveBorderColor = "bg:activebordercolor"
+ MetaKey_LayoutVTabBarWidth = "layout:vtabbarwidth"
+
MetaKey_WaveAiPanelOpen = "waveai:panelopen"
MetaKey_WaveAiPanelWidth = "waveai:panelwidth"
MetaKey_WaveAiModel = "waveai:model"
diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go
index adda079c1f..027ff3eff2 100644
--- a/pkg/waveobj/wtypemeta.go
+++ b/pkg/waveobj/wtypemeta.go
@@ -100,6 +100,9 @@ type MetaTSType struct {
BgBorderColor string `json:"bg:bordercolor,omitempty"` // frame:bordercolor
BgActiveBorderColor string `json:"bg:activebordercolor,omitempty"` // frame:activebordercolor
+ // for workspace
+ LayoutVTabBarWidth int `json:"layout:vtabbarwidth,omitempty"`
+
// for tabs+waveai
WaveAiPanelOpen bool `json:"waveai:panelopen,omitempty"`
WaveAiPanelWidth int `json:"waveai:panelwidth,omitempty"`
diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json
index ab10987fc9..8ed6af7235 100644
--- a/pkg/wconfig/defaultconfig/settings.json
+++ b/pkg/wconfig/defaultconfig/settings.json
@@ -4,6 +4,7 @@
"ai:maxtokens": 4000,
"ai:timeoutms": 60000,
"app:defaultnewblock": "term",
+ "app:tabbar": "top",
"app:confirmquit": true,
"app:hideaibutton": false,
"app:disablectrlshiftarrows": false,
diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go
index 084dab1793..8195495ad2 100644
--- a/pkg/wconfig/metaconsts.go
+++ b/pkg/wconfig/metaconsts.go
@@ -17,6 +17,7 @@ const (
ConfigKey_AppDisableCtrlShiftArrows = "app:disablectrlshiftarrows"
ConfigKey_AppDisableCtrlShiftDisplay = "app:disablectrlshiftdisplay"
ConfigKey_AppFocusFollowsCursor = "app:focusfollowscursor"
+ ConfigKey_AppTabBar = "app:tabbar"
ConfigKey_FeatureWaveAppBuilder = "feature:waveappbuilder"
diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go
index 17aafa6685..b1b10d977f 100644
--- a/pkg/wconfig/settingsconfig.go
+++ b/pkg/wconfig/settingsconfig.go
@@ -68,6 +68,7 @@ type SettingsType struct {
AppDisableCtrlShiftArrows bool `json:"app:disablectrlshiftarrows,omitempty"`
AppDisableCtrlShiftDisplay bool `json:"app:disablectrlshiftdisplay,omitempty"`
AppFocusFollowsCursor string `json:"app:focusfollowscursor,omitempty" jsonschema:"enum=off,enum=on,enum=term"`
+ AppTabBar string `json:"app:tabbar,omitempty" jsonschema:"enum=top,enum=left"`
FeatureWaveAppBuilder bool `json:"feature:waveappbuilder,omitempty"`
diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go
index 110e1695ef..103089144e 100644
--- a/pkg/wshrpc/wshclient/wshclient.go
+++ b/pkg/wshrpc/wshclient/wshclient.go
@@ -615,6 +615,12 @@ func ListAllEditableAppsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshr
return resp, err
}
+// command "macosversion", wshserver.MacOSVersionCommand
+func MacOSVersionCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) {
+ resp, err := sendRpcRequestCallHelper[string](w, "macosversion", nil, opts)
+ return resp, err
+}
+
// command "makedraftfromlocal", wshserver.MakeDraftFromLocalCommand
func MakeDraftFromLocalCommand(w *wshutil.WshRpc, data wshrpc.CommandMakeDraftFromLocalData, opts *wshrpc.RpcOpts) (*wshrpc.CommandMakeDraftFromLocalRtnData, error) {
resp, err := sendRpcRequestCallHelper[*wshrpc.CommandMakeDraftFromLocalRtnData](w, "makedraftfromlocal", data, opts)
diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go
index 8ddff8128b..2fee3e392e 100644
--- a/pkg/wshrpc/wshrpctypes.go
+++ b/pkg/wshrpc/wshrpctypes.go
@@ -83,6 +83,7 @@ type WshRpcInterface interface {
DebugTermCommand(ctx context.Context, data CommandDebugTermData) (*CommandDebugTermRtnData, error)
BlocksListCommand(ctx context.Context, data BlocksListRequest) ([]BlocksListEntry, error)
WaveInfoCommand(ctx context.Context) (*WaveInfoData, error)
+ MacOSVersionCommand(ctx context.Context) (string, error)
WshActivityCommand(ct context.Context, data map[string]int) error
ActivityCommand(ctx context.Context, data ActivityUpdate) error
RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error
diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go
index 670c949f2e..b9d320f697 100644
--- a/pkg/wshrpc/wshserver/wshserver.go
+++ b/pkg/wshrpc/wshserver/wshserver.go
@@ -878,6 +878,10 @@ func (ws *WshServer) WaveInfoCommand(ctx context.Context) (*wshrpc.WaveInfoData,
}, nil
}
+func (ws *WshServer) MacOSVersionCommand(ctx context.Context) (string, error) {
+ return wavebase.ClientMacOSVersion(), nil
+}
+
// BlocksListCommand returns every block visible in the requested
// scope (current workspace by default).
func (ws *WshServer) BlocksListCommand(
diff --git a/schema/settings.json b/schema/settings.json
index 348c937dac..5213fed365 100644
--- a/schema/settings.json
+++ b/schema/settings.json
@@ -43,6 +43,13 @@
"term"
]
},
+ "app:tabbar": {
+ "type": "string",
+ "enum": [
+ "top",
+ "left"
+ ]
+ },
"feature:waveappbuilder": {
"type": "boolean"
},