Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
checkpoint, resizeable panel for vtabbar
  • Loading branch information
sawka committed Mar 13, 2026
commit 4f269be7778907eac1462519c5079ddc9953edc7
140 changes: 102 additions & 38 deletions frontend/app/workspace/workspace-layout-model.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,48 +16,70 @@ 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 = 100;
const VTabBar_MaxWidth = 280;

class WorkspaceLayoutModel {
private static instance: WorkspaceLayoutModel | null = null;

aiPanelRef: ImperativePanelHandle | null;
vtabPanelRef: ImperativePanelHandle | null;
panelGroupRef: ImperativePanelGroupHandle | null;
panelContainerRef: HTMLDivElement | null;
aiPanelWrapperRef: HTMLDivElement | null;
inResize: boolean; // prevents recursive setLayout calls (setLayout triggers onLayout which calls setLayout)
private aiPanelVisible: boolean;
private aiPanelWidth: number | null;
private debouncedPersistWidth: (width: number) => void;
private vtabWidth: number;
private showLeftTabBar: boolean;
private debouncedPersistAIWidth: (width: number) => void;
private debouncedPersistVTabWidth: (width: number) => void;
private initialized: boolean = false;
private transitionTimeoutRef: NodeJS.Timeout | null = null;
private focusTimeoutRef: NodeJS.Timeout | null = null;
panelVisibleAtom: jotai.PrimitiveAtom<boolean>;

private constructor() {
this.aiPanelRef = null;
this.vtabPanelRef = null;
this.panelGroupRef = null;
this.panelContainerRef = null;
this.aiPanelWrapperRef = null;
this.inResize = false;
this.aiPanelVisible = false;
this.aiPanelWidth = null;
this.vtabWidth = VTabBar_DefaultWidth;
this.showLeftTabBar = false;
this.panelVisibleAtom = jotai.atom(this.aiPanelVisible);

this.handleWindowResize = this.handleWindowResize.bind(this);
this.handlePanelLayout = this.handlePanelLayout.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);
}
Expand All @@ -68,20 +91,24 @@ class WorkspaceLayoutModel {
return WorkspaceLayoutModel.instance;
}

private initializeFromTabMeta(): void {
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);
Expand All @@ -92,6 +119,15 @@ class WorkspaceLayoutModel {
return globalStore.get(atoms.staticTabId);
}

private getWorkspaceId(): string {
return globalStore.get(atoms.workspace)?.oid ?? "";
}

private getVTabBarWidthAtom(): jotai.Atom<number> {
const wsORef = WOS.makeORef("workspace", this.getWorkspaceId());
return getOrefMetaKeyAtom(wsORef, "layout:vtabbarwidth");
}

private getPanelOpenAtom(): jotai.Atom<boolean> {
const tabORef = WOS.makeORef("tab", this.getTabId());
return getOrefMetaKeyAtom(tabORef, "waveai:panelopen");
Expand All @@ -106,9 +142,13 @@ class WorkspaceLayoutModel {
aiPanelRef: ImperativePanelHandle,
panelGroupRef: ImperativePanelGroupHandle,
panelContainerRef: HTMLDivElement,
aiPanelWrapperRef: HTMLDivElement
aiPanelWrapperRef: HTMLDivElement,
vtabPanelRef?: ImperativePanelHandle,
showLeftTabBar?: boolean
): void {
this.aiPanelRef = aiPanelRef;
this.vtabPanelRef = vtabPanelRef ?? null;
this.showLeftTabBar = showLeftTabBar ?? false;
this.panelGroupRef = panelGroupRef;
this.panelContainerRef = panelContainerRef;
this.aiPanelWrapperRef = aiPanelWrapperRef;
Expand Down Expand Up @@ -153,10 +193,8 @@ class WorkspaceLayoutModel {
return;
}
const newWindowWidth = window.innerWidth;
const aiPanelPercentage = this.getAIPanelPercentage(newWindowWidth);
const mainContentPercentage = this.getMainContentPercentage(newWindowWidth);
const layout = this.buildLayout(newWindowWidth);
this.inResize = true;
const layout = [aiPanelPercentage, mainContentPercentage];
this.panelGroupRef.setLayout(layout);
this.inResize = false;
this.updateWrapperWidth();
Expand All @@ -172,51 +210,82 @@ class WorkspaceLayoutModel {
}

const currentWindowWidth = window.innerWidth;
const aiPanelPixelWidth = (sizes[0] / 100) * currentWindowWidth;
if (this.showLeftTabBar && sizes[0] > 0) {
const vtabPixelWidth = (sizes[0] / 100) * currentWindowWidth;
const clamped = Math.max(VTabBar_MinWidth, Math.min(vtabPixelWidth, VTabBar_MaxWidth));
if (clamped !== this.vtabWidth) {
this.vtabWidth = clamped;
this.debouncedPersistVTabWidth(clamped);
}
}
const aiPanelPixelWidth = (sizes[1] / 100) * currentWindowWidth;
this.handleAIPanelResize(aiPanelPixelWidth, currentWindowWidth);
const newPercentage = this.getAIPanelPercentage(currentWindowWidth);
const mainContentPercentage = 100 - newPercentage;
const layout = this.buildLayout(currentWindowWidth);
this.inResize = true;
const layout = [newPercentage, mainContentPercentage];
this.panelGroupRef.setLayout(layout);
this.inResize = false;
}

buildLayout(windowWidth: number): number[] {
const vtabPercentage = this.showLeftTabBar ? this.getVTabBarPercentage(windowWidth) : 0;
const aiPanelPercentage = this.getAIPanelPercentage(windowWidth);
const contentPercentage = Math.max(0, 100 - vtabPercentage - aiPanelPercentage);
return [vtabPercentage, aiPanelPercentage, contentPercentage];
}

syncAIPanelRef(): void {
if (!this.aiPanelRef || !this.panelGroupRef) {
return;
}

const currentWindowWidth = window.innerWidth;
const aiPanelPercentage = this.getAIPanelPercentage(currentWindowWidth);
const mainContentPercentage = this.getMainContentPercentage(currentWindowWidth);

if (this.getAIPanelVisible()) {
this.aiPanelRef.expand();
} else {
this.aiPanelRef.collapse();
}

const currentWindowWidth = window.innerWidth;
const layout = this.buildLayout(currentWindowWidth);
this.inResize = true;
const layout = [aiPanelPercentage, mainContentPercentage];
this.panelGroupRef.setLayout(layout);
this.inResize = false;
}

getVTabBarPercentage(windowWidth: number): number {
const clamped = Math.max(VTabBar_MinWidth, Math.min(this.vtabWidth, VTabBar_MaxWidth));
return (clamped / windowWidth) * 100;
}

getVTabBarInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number {
if (!showLeftTabBar || isBuilderWindow()) {
return 0;
}
this.initializeFromMeta();
return this.getVTabBarPercentage(windowWidth);
}

getVTabBarMinPercentage(windowWidth: number): number {
return (VTabBar_MinWidth / windowWidth) * 100;
}

getVTabBarMaxPercentage(windowWidth: number): number {
return (VTabBar_MaxWidth / windowWidth) * 100;
}

getMaxAIPanelWidth(windowWidth: number): number {
return Math.floor(windowWidth * AIPANEL_MAXWIDTHRATIO);
return Math.floor(windowWidth * AIPanel_MaxWidthRatio);
}

getClampedAIPanelWidth(width: number, windowWidth: number): number {
const maxWidth = this.getMaxAIPanelWidth(windowWidth);
if (AIPANEL_MINWIDTH > maxWidth) {
return AIPANEL_MINWIDTH;
if (AIPanel_MinWidth > maxWidth) {
return AIPanel_MinWidth;
}
return Math.max(AIPANEL_MINWIDTH, Math.min(width, maxWidth));
return Math.max(AIPanel_MinWidth, Math.min(width, maxWidth));
}

getAIPanelVisible(): boolean {
this.initializeFromTabMeta();
this.initializeFromMeta();
return this.aiPanelVisible;
}

Expand Down Expand Up @@ -261,17 +330,17 @@ class WorkspaceLayoutModel {
}

getAIPanelWidth(): number {
this.initializeFromTabMeta();
this.initializeFromMeta();
if (this.aiPanelWidth == null) {
this.aiPanelWidth = Math.max(AIPANEL_DEFAULTWIDTH, window.innerWidth * AIPANEL_DEFAULTWIDTHRATIO);
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);
this.debouncedPersistAIWidth(width);
}

getAIPanelPercentage(windowWidth: number): number {
Expand All @@ -285,11 +354,6 @@ class WorkspaceLayoutModel {
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;
Expand Down
30 changes: 22 additions & 8 deletions frontend/app/workspace/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,14 @@ const WorkspaceElem = memo(() => {
const ws = useAtomValue(atoms.workspace);
const tabBarPosition = useAtomValue(getSettingsKeyAtom("app:tabbar")) ?? "top";
const showLeftTabBar = tabBarPosition === "left";
const initialAiPanelPercentage = workspaceLayoutModel.getAIPanelPercentage(window.innerWidth);
const windowWidth = window.innerWidth;
const initialAiPanelPercentage = workspaceLayoutModel.getAIPanelPercentage(windowWidth);
const vtabInitialPct = workspaceLayoutModel.getVTabBarInitialPercentage(windowWidth, showLeftTabBar);
const vtabMinPct = workspaceLayoutModel.getVTabBarMinPercentage(windowWidth);
const vtabMaxPct = workspaceLayoutModel.getVTabBarMaxPercentage(windowWidth);
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const aiPanelRef = useRef<ImperativePanelHandle>(null);
const vtabPanelRef = useRef<ImperativePanelHandle>(null);
const panelContainerRef = useRef<HTMLDivElement>(null);
const aiPanelWrapperRef = useRef<HTMLDivElement>(null);

Expand All @@ -39,7 +44,9 @@ const WorkspaceElem = memo(() => {
aiPanelRef.current,
panelGroupRef.current,
panelContainerRef.current,
aiPanelWrapperRef.current
aiPanelWrapperRef.current,
vtabPanelRef.current ?? undefined,
showLeftTabBar
);
}
}, []);
Comment on lines 63 to 81
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing showLeftTabBar in dependency array.

The useEffect has an empty dependency array [], but the callback uses showLeftTabBar (line 58). This means if the user changes the app:tabbar setting at runtime, the refs won't be re-registered with the updated showLeftTabBar value. While line 68-70 has a separate effect calling setShowLeftTabBar, the initial registration may use a stale value if showLeftTabBar changes before refs are ready.

Proposed fix
         workspaceLayoutModel.registerRefs(
             aiPanelRef.current,
             outerPanelGroupRef.current,
             innerPanelGroupRef.current,
             panelContainerRef.current,
             aiPanelWrapperRef.current,
             vtabPanelRef.current ?? undefined,
             showLeftTabBar
         );
     }
-}, []);
+}, [showLeftTabBar]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
);
}
}, []);
useEffect(() => {
if (
aiPanelRef.current &&
outerPanelGroupRef.current &&
innerPanelGroupRef.current &&
panelContainerRef.current &&
aiPanelWrapperRef.current
) {
workspaceLayoutModel.registerRefs(
aiPanelRef.current,
outerPanelGroupRef.current,
innerPanelGroupRef.current,
panelContainerRef.current,
aiPanelWrapperRef.current,
vtabPanelRef.current ?? undefined,
showLeftTabBar
);
}
}, [showLeftTabBar]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/workspace/workspace.tsx` around lines 43 - 61, The effect that
calls workspaceLayoutModel.registerRefs currently uses showLeftTabBar but has an
empty dependency array; update the useEffect dependencies to include
showLeftTabBar so the refs are re-registered when the tabbar setting changes,
keeping the existing guard that checks aiPanelRef.current,
outerPanelGroupRef.current, innerPanelGroupRef.current,
panelContainerRef.current, and aiPanelWrapperRef.current before calling
workspaceLayoutModel.registerRefs (also still pass vtabPanelRef.current ??
undefined and showLeftTabBar as existing).

Expand All @@ -58,17 +65,24 @@ const WorkspaceElem = memo(() => {
<div className="flex flex-col w-full flex-grow overflow-hidden">
<TabBar key={ws.oid} workspace={ws} noTabs={showLeftTabBar} />
<div ref={panelContainerRef} className="flex flex-row flex-grow overflow-hidden">
{showLeftTabBar && (
<div className="min-w-[100px]">
<VTabBar workspace={ws} />
</div>
)}
<ErrorBoundary key={tabId}>
<PanelGroup
direction="horizontal"
onLayout={workspaceLayoutModel.handlePanelLayout}
ref={panelGroupRef}
>
<Panel
ref={vtabPanelRef}
collapsible
defaultSize={vtabInitialPct}
minSize={vtabMinPct}
maxSize={vtabMaxPct}
order={0}
className="overflow-hidden"
>
{showLeftTabBar && <VTabBar workspace={ws} />}
</Panel>
<PanelResizeHandle className="w-0.5 bg-transparent hover:bg-zinc-500/20 transition-colors" />
<Panel
ref={aiPanelRef}
collapsible
Expand All @@ -81,7 +95,7 @@ const WorkspaceElem = memo(() => {
</div>
</Panel>
<PanelResizeHandle className="w-0.5 bg-transparent hover:bg-zinc-500/20 transition-colors" />
<Panel order={2} defaultSize={100 - initialAiPanelPercentage}>
<Panel order={2} defaultSize={100 - vtabInitialPct - initialAiPanelPercentage}>
{tabId === "" ? (
<CenteredDiv>No Active Tab</CenteredDiv>
) : (
Expand Down
1 change: 1 addition & 0 deletions frontend/types/gotypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions pkg/waveobj/metaconsts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions pkg/waveobj/wtypemeta.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down