diff --git a/.gitignore b/.gitignore index 161db5f191..a1c7240b5a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ out/ make/ artifacts/ mikework/ +aiplans/ manifests/ .env out diff --git a/Taskfile.yml b/Taskfile.yml index 80903ad60b..106ac99e0b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -282,6 +282,18 @@ tasks: - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wsh*" platforms: [windows] ignore_error: true + - task: build:wsh:parallel + deps: + - go:mod:tidy + - generate + sources: + - "cmd/wsh/**/*.go" + - "pkg/**/*.go" + generates: + - "dist/bin/wsh*" + + build:wsh:parallel: + deps: - task: build:wsh:internal vars: GOOS: darwin @@ -314,14 +326,7 @@ tasks: vars: GOOS: windows GOARCH: arm64 - deps: - - go:mod:tidy - - generate - sources: - - "cmd/wsh/**/*.go" - - "pkg/**/*.go" - generates: - - "dist/bin/wsh*" + internal: true build:wsh:internal: vars: diff --git a/cmd/wsh/cmd/wshcmd-root.go b/cmd/wsh/cmd/wshcmd-root.go index 48a568d69c..534ce0c31a 100644 --- a/cmd/wsh/cmd/wshcmd-root.go +++ b/cmd/wsh/cmd/wshcmd-root.go @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 8a8a6330a0..ae83638ea5 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -44,6 +44,7 @@ wsh editconfig | app:disablectrlshiftarrows | bool | Set to true to disable Ctrl+Shift block-navigation keybindings (`Arrow` and `h/j/k/l`) (defaults to false) | | app:disablectrlshiftdisplay | bool | Set to true to disable the Ctrl+Shift visual indicator display (defaults to false) | | app:focusfollowscursor | string | Controls whether block focus follows cursor movement: `"off"` (default), `"on"` (all blocks), or `"term"` (terminal blocks only) | +| app:tabbar | string | Controls the position of the tab bar: `"top"` (default) for a horizontal tab bar at the top of the window, or `"left"` for a vertical tab bar on the left side of the window | | ai:preset | string | the default AI preset to use | | ai:baseurl | string | Set the AI Base Url (must be OpenAI compatible) | | ai:apitoken | string | your AI api token | diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 112a4cc79e..37c22709fe 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -245,7 +245,11 @@ const ConfigChangeModeFixer = memo(() => { ConfigChangeModeFixer.displayName = "ConfigChangeModeFixer"; -const AIPanelComponentInner = memo(() => { +type AIPanelComponentInnerProps = { + roundTopLeft: boolean; +}; + +const AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps) => { const [isDragOver, setIsDragOver] = useState(false); const [isReactDndDragOver, setIsReactDndDragOver] = useState(false); const [initialLoadDone, setInitialLoadDone] = useState(false); @@ -554,6 +558,7 @@ const AIPanelComponentInner = memo(() => { isFocused ? "border-2 border-accent" : "border-2 border-transparent" )} style={{ + borderTopLeftRadius: roundTopLeft ? 10 : 0, borderTopRightRadius: model.inBuilder ? 0 : 10, borderBottomRightRadius: model.inBuilder ? 0 : 10, borderBottomLeftRadius: 10, @@ -607,10 +612,14 @@ const AIPanelComponentInner = memo(() => { AIPanelComponentInner.displayName = "AIPanelInner"; -const AIPanelComponent = () => { +type AIPanelComponentProps = { + roundTopLeft: boolean; +}; + +const AIPanelComponent = ({ roundTopLeft }: AIPanelComponentProps) => { return ( - + ); }; diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 6b9f4a72d4..d64f7f06b0 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -618,6 +618,12 @@ export class RpcApiType { return client.wshRpcCall("listalleditableapps", null, opts); } + // command "macosversion" [call] + MacOSVersionCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "macosversion", null, opts); + return client.wshRpcCall("macosversion", null, opts); + } + // command "makedraftfromlocal" [call] MakeDraftFromLocalCommand(client: WshClient, data: CommandMakeDraftFromLocalData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "makedraftfromlocal", data, opts); diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 6b3679bb37..7b2aa6856e 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { getTabBadgeAtom } from "@/app/store/badge"; -import { getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global"; +import { refocusNode } from "@/app/store/global"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv"; import { Button } from "@/element/button"; @@ -14,10 +14,12 @@ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, import { makeORef } from "../store/wos"; import { TabBadges } from "./tabbadges"; import "./tab.scss"; +import { buildTabContextMenu } from "./tabcontextmenu"; -type TabEnv = WaveEnvSubset<{ +export type TabEnv = WaveEnvSubset<{ rpc: { ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; + SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"]; }; @@ -25,6 +27,7 @@ type TabEnv = WaveEnvSubset<{ fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; }; wos: WaveEnv["wos"]; + getSettingsKeyAtom: WaveEnv["getSettingsKeyAtom"]; showContextMenu: WaveEnv["showContextMenu"]; }>; @@ -216,88 +219,6 @@ const TabV = forwardRef((props, ref) => { TabV.displayName = "TabV"; -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" }, -]; - -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({ label: "Close Tab", click: () => onClose(null) }); - return menu; -} - interface TabProps { id: string; active: boolean; diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index a5cbff3398..fdb1291ea0 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -7,6 +7,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { deleteLayoutModelForTab } from "@/layout/index"; +import { isMacOSTahoeOrLater } from "@/util/platformutil"; import { fireAndForget } from "@/util/util"; import { useAtomValue } from "jotai"; import { OverlayScrollbars } from "overlayscrollbars"; @@ -20,6 +21,9 @@ import { WorkspaceSwitcher } from "./workspaceswitcher"; const TabDefaultWidth = 130; const TabMinWidth = 100; +const MacOSTrafficLightsWidth = 74; +const MacOSTahoeTrafficLightsWidth = 80; + const OSOptions = { overflow: { x: "scroll", @@ -39,6 +43,7 @@ const OSOptions = { interface TabBarProps { workspace: Workspace; + noTabs?: boolean; } const WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject }) => { @@ -152,7 +157,7 @@ function strArrayIsEqual(a: string[], b: string[]) { return true; } -const TabBar = memo(({ workspace }: TabBarProps) => { +const TabBar = memo(({ workspace, noTabs }: TabBarProps) => { const env = useWaveEnv(); const [tabIds, setTabIds] = useState([]); const [dragStartPositions, setDragStartPositions] = useState([]); @@ -635,10 +640,13 @@ const TabBar = memo(({ workspace }: TabBarProps) => { // Calculate window drag left width based on platform and state let windowDragLeftWidth = 10; if (env.isMacOS() && !isFullScreen) { + const trafficLightsWidth = isMacOSTahoeOrLater() + ? MacOSTahoeTrafficLightsWidth + : MacOSTrafficLightsWidth; if (zoomFactor > 0) { - windowDragLeftWidth = 74 / zoomFactor; + windowDragLeftWidth = trafficLightsWidth / zoomFactor; } else { - windowDragLeftWidth = 74; + windowDragLeftWidth = trafficLightsWidth; } } @@ -680,33 +688,41 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
-
- {tabIds.map((tabId, index) => { - const isActive = activeTabId === tabId; - const showDivider = index !== 0 && !isActive && index !== activeTabIndex + 1; - return ( - handleSelectTab(tabId)} - active={isActive} - onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])} - onClose={(event) => handleCloseTab(event, tabId)} - onLoaded={() => handleTabLoaded(tabId)} - isDragging={draggingTab === tabId} - tabWidth={tabWidthRef.current} - isNew={tabId === newTabId} - /> - ); - })} +
+ {!noTabs && + tabIds.map((tabId, index) => { + const isActive = activeTabId === tabId; + const showDivider = index !== 0 && !isActive && index !== activeTabIndex + 1; + return ( + handleSelectTab(tabId)} + active={isActive} + onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])} + onClose={(event) => handleCloseTab(event, tabId)} + onLoaded={() => handleTabLoaded(tabId)} + isDragging={draggingTab === tabId} + tabWidth={tabWidthRef.current} + isNew={tabId === newTabId} + /> + ); + })}
+ 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() && }
- -
- {tabId !== "" && } -
+ + + + {showLeftTabBar && } + + + +
+ {tabId !== "" && } +
+
+
- - + + {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" },