+
{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 0634645789..f8cbc751fb 100644
--- a/frontend/app/tab/vtabbar.tsx
+++ b/frontend/app/tab/vtabbar.tsx
@@ -1,40 +1,92 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
+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 { useEffect, useMemo, useRef, useState } from "react";
+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().panelVisibleAtom);
+ const hideAiButton = useAtomValue(env.getSettingsKeyAtom("app:hideaibutton"));
+
+ const onClick = () => {
+ const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible();
+ WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible);
+ };
+
+ 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 {
workspace: Workspace;
- width?: number;
className?: string;
}
-function clampWidth(width?: number): number {
- if (width == null) {
- return 220;
- }
- if (width < 100) {
- return 100;
- }
- if (width > 400) {
- return 400;
- }
- return width;
-}
-
interface VTabWrapperProps {
tabId: string;
active: boolean;
+ showDivider: boolean;
isDragging: boolean;
isReordering: boolean;
hoverResetVersion: number;
@@ -46,11 +98,13 @@ interface VTabWrapperProps {
onDragOver: (event: React.DragEvent) => void;
onDrop: (event: React.DragEvent) => void;
onDragEnd: () => void;
+ onHoverChanged: (isHovered: boolean) => void;
}
function VTabWrapper({
tabId,
active,
+ showDivider,
isDragging,
isReordering,
hoverResetVersion,
@@ -61,10 +115,12 @@ function VTabWrapper({
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;
@@ -84,28 +140,43 @@ function VTabWrapper({
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({ workspace, width, className }: VTabBarProps) {
+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);
@@ -113,8 +184,14 @@ export function VTabBar({ workspace, width, className }: VTabBarProps) {
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);
@@ -126,9 +203,75 @@ export function VTabBar({ workspace, width, className }: VTabBarProps) {
}
}, [reinitVersion]);
- const barWidth = useMemo(() => clampWidth(width), [width]);
+ useEffect(() => {
+ if (activeTabId == null || scrollContainerRef.current == null) {
+ return;
+ }
+ const el = scrollContainerRef.current.querySelector(`[data-tabid="${activeTabId}"]`);
+ el?.scrollIntoView({ block: "nearest" });
+ }, [activeTabId]);
+
+ useEffect(() => {
+ if (!documentHasFocus || activeTabId == null || scrollContainerRef.current == null) {
+ return;
+ }
+ const el = scrollContainerRef.current.querySelector(`[data-tabid="${activeTabId}"]`);
+ el?.scrollIntoView({ block: "nearest" });
+ }, [documentHasFocus]);
+
+ 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);
@@ -160,18 +303,28 @@ export function VTabBar({ workspace, width, className }: VTabBarProps) {
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(orderedTabIds.length);
setDropLineTop(event.currentTarget.scrollHeight);
@@ -185,61 +338,68 @@ export function VTabBar({ workspace, width, className }: VTabBarProps) {
clearDragState();
}}
>
- {orderedTabIds.map((tabId, index) => (
-
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);
+ {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 && (
)}
+
);
}
diff --git a/frontend/app/tab/vtabbarenv.ts b/frontend/app/tab/vtabbarenv.ts
index 6926f35131..2533780776 100644
--- a/frontend/app/tab/vtabbarenv.ts
+++ b/frontend/app/tab/vtabbarenv.ts
@@ -8,18 +8,33 @@ export type VTabBarEnv = WaveEnvSubset<{
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"];
- getSettingsKeyAtom: SettingsKeyAtomFnType<"tab:confirmclose">;
+ 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/workspace/workspace-layout-model.ts b/frontend/app/workspace/workspace-layout-model.ts
index 725c9a17b5..30b6418172 100644
--- a/frontend/app/workspace/workspace-layout-model.ts
+++ b/frontend/app/workspace/workspace-layout-model.ts
@@ -1,8 +1,9 @@
-// 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";
@@ -15,48 +16,86 @@ 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;
+
+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;
+
+ 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.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 +107,223 @@ class WorkspaceLayoutModel {
return WorkspaceLayoutModel.instance;
}
- private initializeFromTabMeta(): void {
+ // ---- Meta / persistence helpers ----
+
+ 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);
}
- 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);
+ // ---- Resolved width getters (always clamped) ----
+
+ 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");
+ // ---- Core layout computation ----
+ // All layout decisions flow through computeLayout.
+ // It takes the current state (visibility flags + stored px widths)
+ // and produces the two percentage arrays for the panel groups.
+
+ 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;
+
+ // outer: [leftGroupPct, contentPct]
+ const leftPct = windowWidth > 0 ? (leftGroupW / windowWidth) * 100 : 0;
+ const contentPct = Math.max(0, 100 - leftPct);
+
+ // inner: [vtabPct, aiPanelPct] relative to leftGroupW
+ 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();
+ }
+
+ // ---- Drag handlers ----
+ // These convert the percentage-based callback from react-resizable-panels
+ // back into pixel widths, update stored state, then re-commit.
+
+ handleOuterPanelLayout(sizes: number[]): void {
+ if (this.inResize) return;
+ const windowWidth = window.innerWidth;
+ const newLeftGroupPx = (sizes[0] / 100) * windowWidth;
+
+ if (this.vtabVisible && this.aiPanelVisible) {
+ // vtab stays constant, aipanel absorbs the change
+ 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);
+ }
+
+ // ---- Registration & sync ----
+
+ 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`;
}
+ // ---- Transitions ----
+
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,77 +331,54 @@ 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();
+ // ---- Wrapper width (AI panel inner content width) ----
+
+ 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;
- }
+ // ---- Public getters ----
- 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);
+ getAIPanelWidth(): number {
+ return this.getResolvedAIWidth(window.innerWidth);
+ }
- if (this.getAIPanelVisible()) {
- this.aiPanelRef.expand();
- } else {
- this.aiPanelRef.collapse();
- }
+ // ---- Initial percentage helpers (used by workspace.tsx for defaultSize) ----
- this.inResize = true;
- const layout = [aiPanelPercentage, mainContentPercentage];
- this.panelGroupRef.setLayout(layout);
- this.inResize = false;
+ 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;
}
- getMaxAIPanelWidth(windowWidth: number): number {
- return Math.floor(windowWidth * AIPANEL_MAXWIDTHRATIO);
+ 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;
}
- 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));
+ 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;
}
- getAIPanelVisible(): boolean {
- this.initializeFromTabMeta();
- return this.aiPanelVisible;
- }
+ // ---- Toggle visibility ----
setAIPanelVisible(visible: boolean, opts?: { nofocus?: boolean }): void {
if (this.focusTimeoutRef != null) {
@@ -237,7 +397,8 @@ class WorkspaceLayoutModel {
meta: { "waveai:panelopen": visible },
});
this.enableTransitions(250);
- this.syncAIPanelRef();
+ this.syncPanelCollapse();
+ this.commitLayouts(window.innerWidth);
if (visible) {
if (!opts?.nofocus) {
@@ -260,42 +421,13 @@ class WorkspaceLayoutModel {
}
}
- getAIPanelWidth(): number {
- this.initializeFromTabMeta();
- if (this.aiPanelWidth == null) {
- this.aiPanelWidth = Math.max(AIPANEL_DEFAULTWIDTH, window.innerWidth * AIPANEL_DEFAULTWIDTHRATIO);
- }
- return this.aiPanelWidth;
- }
-
- setAIPanelWidth(width: number): void {
- this.aiPanelWidth = width;
- this.updateWrapperWidth();
- this.debouncedPersistWidth(width);
- }
-
- getAIPanelPercentage(windowWidth: number): number {
- const isVisible = this.getAIPanelVisible();
- if (!isVisible) {
- return 0;
- }
- 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);
- }
-
- handleAIPanelResize(width: number, windowWidth: number): void {
- if (!this.getAIPanelVisible()) {
- return;
- }
- const clampedWidth = this.getClampedAIPanelWidth(width, windowWidth);
- this.setAIPanelWidth(clampedWidth);
+ 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..988186a370 100644
--- a/frontend/app/workspace/workspace.tsx
+++ b/frontend/app/workspace/workspace.tsx
@@ -1,4 +1,4 @@
-// Copyright 2025, Command Line Inc.
+// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { AIPanel } from "@/app/aipanel/aipanel";
@@ -7,9 +7,11 @@ import { CenteredDiv } from "@/app/element/quickelems";
import { ModalsRenderer } from "@/app/modals/modalsrenderer";
import { TabBar } from "@/app/tab/tabbar";
import { TabContent } from "@/app/tab/tabcontent";
+import { VTabBar } from "@/app/tab/vtabbar";
import { Widgets } from "@/app/workspace/widgets";
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
-import { atoms, getApi } from "@/store/global";
+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 +22,60 @@ 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 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);
+ // showLeftTabBar is passed as a seed value only; subsequent changes are handled by setShowLeftTabBar below.
+ // Do NOT add showLeftTabBar as a dep here — re-registering refs on config changes would redundantly re-run commitLayouts.
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,76 @@ 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 === "" ? (
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/previews/vtabbar.preview.tsx b/frontend/preview/previews/vtabbar.preview.tsx
index a814291573..90b7907be2 100644
--- a/frontend/preview/previews/vtabbar.preview.tsx
+++ b/frontend/preview/previews/vtabbar.preview.tsx
@@ -4,52 +4,123 @@
import { loadBadges, LoadBadgesEnv } from "@/app/store/badge";
import { VTabBar } from "@/app/tab/vtabbar";
import { VTabBarEnv } from "@/app/tab/vtabbarenv";
-import { useWaveEnv } from "@/app/waveenv/waveenv";
-import { TabBarMockEnvProvider, TabBarMockWorkspaceId } from "@/preview/mock/tabbar-mock";
-import { useAtomValue } from "jotai";
-import { useEffect, useState } from "react";
+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 [width, setWidth] = useState(220);
+ const baseEnv = useWaveEnv();
+ const envRef = useRef(null);
+ const [platform, setPlatform] = useState(PlatformMacOS);
+
+ const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, platform), [platform]);
+
return (
-
-
-
+
+
+
);
}
type VTabBarPreviewInnerProps = {
- width: number;
- setWidth: (width: number) => void;
+ platform: NodeJS.Platform;
+ setPlatform: (platform: NodeJS.Platform) => void;
};
-function VTabBarPreviewInner({ width, setWidth }: VTabBarPreviewInnerProps) {
+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}`));
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.
-
+
+
+
+
+
+
+
-
- {workspace != null &&
}
+
+
+
+ {workspace != null && }
+
);
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/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"
},