Skip to content

Commit 890ee31

Browse files
committed
add 'right' option to app:tabbar (refs #3279)
When tabbar is set to 'right', the vertical tab bar renders at the outer-right edge of the window. Implementation mirrors the existing 'left' layout via react-resizable-panels order props rather than introducing a parallel render path: - pkg/wconfig: extend jsonschema enum to include 'right' - schema/settings.json: same, for the published JSON schema - workspace.tsx: derive showVTabBar (left|right) and tabBarOnRight, flip Panel order props and AI panel padding side accordingly - workspace-layout-model.ts: add tabBarOnRight state, swap sizes[] index in handleOuterPanelLayout / handleInnerPanelLayout when the side group is on the right; rename setShowLeftTabBar to setShowVTabBar and the parameter in the percentage helpers The AI panel sits inboard of the vtab in both configurations: vtab is the outermost edge of the side group on whichever side it is rendered. roundTopLeft on the AI panel only applies in left mode; right mode does not currently round the mirrored corner — left as a follow-up if maintainers want it. Verified: tsc --noEmit and go build of pkg/wconfig produce no errors introduced by this change. Runtime testing has not been performed — maintainer review and a manual smoke test before merge are recommended.
1 parent 2e25ea1 commit 890ee31

4 files changed

Lines changed: 60 additions & 36 deletions

File tree

frontend/app/workspace/workspace-layout-model.ts

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class WorkspaceLayoutModel {
5252
private aiPanelWidth: number | null;
5353
private vtabWidth: number;
5454
private vtabVisible: boolean;
55+
private tabBarOnRight: boolean;
5556
private transitionTimeoutRef: NodeJS.Timeout | null = null;
5657
private focusTimeoutRef: NodeJS.Timeout | null = null;
5758
private debouncedPersistAIWidth: () => void;
@@ -71,6 +72,7 @@ class WorkspaceLayoutModel {
7172
this.aiPanelWidth = null;
7273
this.vtabWidth = VTabBar_DefaultWidth;
7374
this.vtabVisible = false;
75+
this.tabBarOnRight = false;
7476
this.panelVisibleAtom = jotai.atom(false);
7577
this.widgetsSidebarVisibleAtom = jotai.atom(
7678
(get) =>
@@ -157,8 +159,9 @@ class WorkspaceLayoutModel {
157159
this.vtabWidth = savedVTabWidth;
158160
}
159161
const tabBarPosition = globalStore.get(getSettingsKeyAtom("app:tabbar")) ?? "top";
160-
const showLeftTabBar = tabBarPosition === "left" && !isBuilderWindow();
161-
this.vtabVisible = showLeftTabBar;
162+
const showVTabBar = (tabBarPosition === "left" || tabBarPosition === "right") && !isBuilderWindow();
163+
this.vtabVisible = showVTabBar;
164+
this.tabBarOnRight = tabBarPosition === "right" && !isBuilderWindow();
162165
} catch (e) {
163166
console.warn("Failed to initialize from tab meta:", e);
164167
}
@@ -224,7 +227,8 @@ class WorkspaceLayoutModel {
224227
handleOuterPanelLayout(sizes: number[]): void {
225228
if (this.inResize) return;
226229
const windowWidth = window.innerWidth;
227-
const newLeftGroupPx = (sizes[0] / 100) * windowWidth;
230+
const sideGroupPct = this.tabBarOnRight ? sizes[1] : sizes[0];
231+
const newLeftGroupPx = (sideGroupPct / 100) * windowWidth;
228232

229233
if (this.vtabVisible && this.aiPanelVisible) {
230234
// vtab stays constant, aipanel absorbs the change
@@ -251,7 +255,8 @@ class WorkspaceLayoutModel {
251255
const aiW = this.getResolvedAIWidth(windowWidth);
252256
const leftGroupW = vtabW + aiW;
253257

254-
const newVTabW = (sizes[0] / 100) * leftGroupW;
258+
const vtabPct = this.tabBarOnRight ? sizes[1] : sizes[0];
259+
const newVTabW = (vtabPct / 100) * leftGroupW;
255260
const clampedVTab = clampVTabWidth(newVTabW);
256261
const newAIW = clampAIPanelWidth(leftGroupW - clampedVTab, windowWidth);
257262

@@ -289,7 +294,8 @@ class WorkspaceLayoutModel {
289294
aiPanelWrapperRef: HTMLDivElement,
290295
vtabPanelRef?: ImperativePanelHandle,
291296
vtabPanelWrapperRef?: HTMLDivElement,
292-
showLeftTabBar?: boolean
297+
showVTabBar?: boolean,
298+
tabBarOnRight?: boolean
293299
): void {
294300
this.aiPanelRef = aiPanelRef;
295301
this.vtabPanelRef = vtabPanelRef ?? null;
@@ -298,7 +304,8 @@ class WorkspaceLayoutModel {
298304
this.panelContainerRef = panelContainerRef;
299305
this.aiPanelWrapperRef = aiPanelWrapperRef;
300306
this.vtabPanelWrapperRef = vtabPanelWrapperRef ?? null;
301-
this.vtabVisible = showLeftTabBar ?? false;
307+
this.vtabVisible = showVTabBar ?? false;
308+
this.tabBarOnRight = tabBarOnRight ?? false;
302309
this.syncPanelCollapse();
303310
this.commitLayouts(window.innerWidth);
304311
}
@@ -360,23 +367,23 @@ class WorkspaceLayoutModel {
360367

361368
// ---- Initial percentage helpers (used by workspace.tsx for defaultSize) ----
362369

363-
getLeftGroupInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number {
364-
const vtabW = showLeftTabBar && !isBuilderWindow() ? this.getResolvedVTabWidth() : 0;
370+
getLeftGroupInitialPercentage(windowWidth: number, showVTabBar: boolean): number {
371+
const vtabW = showVTabBar && !isBuilderWindow() ? this.getResolvedVTabWidth() : 0;
365372
const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0;
366373
return ((vtabW + aiW) / windowWidth) * 100;
367374
}
368375

369-
getInnerVTabInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number {
370-
if (!showLeftTabBar || isBuilderWindow()) return 0;
376+
getInnerVTabInitialPercentage(windowWidth: number, showVTabBar: boolean): number {
377+
if (!showVTabBar || isBuilderWindow()) return 0;
371378
const vtabW = this.getResolvedVTabWidth();
372379
const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0;
373380
const total = vtabW + aiW;
374381
if (total === 0) return 50;
375382
return (vtabW / total) * 100;
376383
}
377384

378-
getInnerAIPanelInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number {
379-
const vtabW = showLeftTabBar && !isBuilderWindow() ? this.getResolvedVTabWidth() : 0;
385+
getInnerAIPanelInitialPercentage(windowWidth: number, showVTabBar: boolean): number {
386+
const vtabW = showVTabBar && !isBuilderWindow() ? this.getResolvedVTabWidth() : 0;
380387
const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0;
381388
const total = vtabW + aiW;
382389
if (total === 0) return 50;
@@ -426,9 +433,11 @@ class WorkspaceLayoutModel {
426433
}
427434
}
428435

429-
setShowLeftTabBar(showLeftTabBar: boolean): void {
430-
if (this.vtabVisible === showLeftTabBar) return;
431-
this.vtabVisible = showLeftTabBar;
436+
setShowVTabBar(showVTabBar: boolean, tabBarOnRight?: boolean): void {
437+
const newRight = tabBarOnRight ?? this.tabBarOnRight;
438+
if (this.vtabVisible === showVTabBar && this.tabBarOnRight === newRight) return;
439+
this.vtabVisible = showVTabBar;
440+
this.tabBarOnRight = newRight;
432441
this.enableTransitions(250);
433442
this.syncPanelCollapse();
434443
this.commitLayouts(window.innerWidth);

frontend/app/workspace/workspace.tsx

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,15 @@ const WorkspaceElem = memo(() => {
4545
const ws = useAtomValue(atoms.workspace);
4646
const tabBarPosition = useAtomValue(getSettingsKeyAtom("app:tabbar")) ?? "top";
4747
const showLeftTabBar = tabBarPosition === "left";
48+
const showRightTabBar = tabBarPosition === "right";
49+
const showVTabBar = showLeftTabBar || showRightTabBar;
50+
const tabBarOnRight = showRightTabBar;
4851
const aiPanelVisible = useAtomValue(workspaceLayoutModel.panelVisibleAtom);
4952
const widgetsSidebarVisible = useAtomValue(workspaceLayoutModel.widgetsSidebarVisibleAtom);
5053
const windowWidth = window.innerWidth;
51-
const leftGroupInitialPct = workspaceLayoutModel.getLeftGroupInitialPercentage(windowWidth, showLeftTabBar);
52-
const innerVTabInitialPct = workspaceLayoutModel.getInnerVTabInitialPercentage(windowWidth, showLeftTabBar);
53-
const innerAIPanelInitialPct = workspaceLayoutModel.getInnerAIPanelInitialPercentage(windowWidth, showLeftTabBar);
54+
const leftGroupInitialPct = workspaceLayoutModel.getLeftGroupInitialPercentage(windowWidth, showVTabBar);
55+
const innerVTabInitialPct = workspaceLayoutModel.getInnerVTabInitialPercentage(windowWidth, showVTabBar);
56+
const innerAIPanelInitialPct = workspaceLayoutModel.getInnerAIPanelInitialPercentage(windowWidth, showVTabBar);
5457
const outerPanelGroupRef = useRef<ImperativePanelGroupHandle>(null);
5558
const innerPanelGroupRef = useRef<ImperativePanelGroupHandle>(null);
5659
const aiPanelRef = useRef<ImperativePanelHandle>(null);
@@ -59,8 +62,8 @@ const WorkspaceElem = memo(() => {
5962
const aiPanelWrapperRef = useRef<HTMLDivElement>(null);
6063
const vtabPanelWrapperRef = useRef<HTMLDivElement>(null);
6164

62-
// showLeftTabBar is passed as a seed value only; subsequent changes are handled by setShowLeftTabBar below.
63-
// Do NOT add showLeftTabBar as a dep here — re-registering refs on config changes would redundantly re-run commitLayouts.
65+
// showVTabBar / tabBarOnRight are passed as seed values only; subsequent changes flow through setShowVTabBar below.
66+
// Do NOT add them as deps here — re-registering refs on config changes would redundantly re-run commitLayouts.
6467
useEffect(() => {
6568
if (
6669
aiPanelRef.current &&
@@ -77,7 +80,8 @@ const WorkspaceElem = memo(() => {
7780
aiPanelWrapperRef.current,
7881
vtabPanelRef.current ?? undefined,
7982
vtabPanelWrapperRef.current ?? undefined,
80-
showLeftTabBar
83+
showVTabBar,
84+
tabBarOnRight
8185
);
8286
}
8387
}, []);
@@ -93,32 +97,42 @@ const WorkspaceElem = memo(() => {
9397
}, []);
9498

9599
useEffect(() => {
96-
workspaceLayoutModel.setShowLeftTabBar(showLeftTabBar);
97-
}, [showLeftTabBar]);
100+
workspaceLayoutModel.setShowVTabBar(showVTabBar, tabBarOnRight);
101+
}, [showVTabBar, tabBarOnRight]);
98102

99103
useEffect(() => {
100104
const handleFocus = () => workspaceLayoutModel.syncVTabWidthFromMeta();
101105
window.addEventListener("focus", handleFocus);
102106
return () => window.removeEventListener("focus", handleFocus);
103107
}, []);
104108

105-
const innerHandleVisible = showLeftTabBar && aiPanelVisible;
109+
const innerHandleVisible = showVTabBar && aiPanelVisible;
106110
const innerHandleClass = `bg-transparent hover:bg-zinc-500/20 transition-colors ${innerHandleVisible ? "w-0.5" : "w-0 pointer-events-none"}`;
107-
const outerHandleVisible = showLeftTabBar || aiPanelVisible;
111+
const outerHandleVisible = showVTabBar || aiPanelVisible;
108112
const outerHandleClass = `bg-transparent hover:bg-zinc-500/20 transition-colors ${outerHandleVisible ? "w-0.5" : "w-0 pointer-events-none"}`;
109113

114+
// When tabBarOnRight, mirror panel ordering so vtab sits at the outer-right edge
115+
// and content moves to the leftmost panel. The defaultSize percentages stay the
116+
// same; only the `order` prop flips, which is what react-resizable-panels uses
117+
// to determine left-to-right placement.
118+
const sideGroupOrder = tabBarOnRight ? 1 : 0;
119+
const contentOrder = tabBarOnRight ? 0 : 1;
120+
const vtabOrder = tabBarOnRight ? 1 : 0;
121+
const aiPanelOrder = tabBarOnRight ? 0 : 1;
122+
const aiWrapperPaddingClass = tabBarOnRight ? "pl-0.5" : "pr-0.5";
123+
110124
return (
111125
<div className="flex flex-col w-full flex-grow overflow-hidden">
112-
{!(showLeftTabBar && isMacOS()) && <TabBar key={ws.oid} workspace={ws} noTabs={showLeftTabBar} />}
113-
{showLeftTabBar && isMacOS() && <MacOSTabBarSpacer />}
126+
{!(showVTabBar && isMacOS()) && <TabBar key={ws.oid} workspace={ws} noTabs={showVTabBar} />}
127+
{showVTabBar && isMacOS() && <MacOSTabBarSpacer />}
114128
<div ref={panelContainerRef} className="flex flex-row flex-grow overflow-hidden">
115129
<ErrorBoundary key={tabId}>
116130
<PanelGroup
117131
direction="horizontal"
118132
onLayout={workspaceLayoutModel.handleOuterPanelLayout}
119133
ref={outerPanelGroupRef}
120134
>
121-
<Panel order={0} defaultSize={leftGroupInitialPct} className="overflow-hidden">
135+
<Panel order={sideGroupOrder} defaultSize={leftGroupInitialPct} className="overflow-hidden">
122136
<PanelGroup
123137
direction="horizontal"
124138
onLayout={workspaceLayoutModel.handleInnerPanelLayout}
@@ -128,37 +142,37 @@ const WorkspaceElem = memo(() => {
128142
ref={vtabPanelRef}
129143
collapsible
130144
defaultSize={innerVTabInitialPct}
131-
order={0}
145+
order={vtabOrder}
132146
className="overflow-hidden"
133147
>
134148
<div ref={vtabPanelWrapperRef} className="w-full h-full">
135-
{showLeftTabBar && <VTabBar workspace={ws} />}
149+
{showVTabBar && <VTabBar workspace={ws} />}
136150
</div>
137151
</Panel>
138152
<PanelResizeHandle className={innerHandleClass} />
139153
<Panel
140154
ref={aiPanelRef}
141155
collapsible
142156
defaultSize={innerAIPanelInitialPct}
143-
order={1}
157+
order={aiPanelOrder}
144158
className="overflow-hidden"
145159
>
146160
<div
147161
ref={aiPanelWrapperRef}
148-
className={`w-full h-full pr-0.5 ${aiPanelVisible ? "" : "opacity-0"}`}
162+
className={`w-full h-full ${aiWrapperPaddingClass} ${aiPanelVisible ? "" : "opacity-0"}`}
149163
>
150164
{tabId !== "" && <AIPanel roundTopLeft={showLeftTabBar} />}
151165
</div>
152166
</Panel>
153167
</PanelGroup>
154168
</Panel>
155169
<PanelResizeHandle className={outerHandleClass} />
156-
<Panel order={1} defaultSize={100 - leftGroupInitialPct}>
170+
<Panel order={contentOrder} defaultSize={100 - leftGroupInitialPct}>
157171
{tabId === "" ? (
158172
<CenteredDiv>No Active Tab</CenteredDiv>
159173
) : (
160174
<div className="flex flex-row h-full">
161-
<TabContent key={tabId} tabId={tabId} noTopPadding={showLeftTabBar && isMacOS()} />
175+
<TabContent key={tabId} tabId={tabId} noTopPadding={showVTabBar && isMacOS()} />
162176
{widgetsSidebarVisible && <Widgets />}
163177
</div>
164178
)}

pkg/wconfig/settingsconfig.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ type SettingsType struct {
6868
AppDisableCtrlShiftArrows bool `json:"app:disablectrlshiftarrows,omitempty"`
6969
AppDisableCtrlShiftDisplay bool `json:"app:disablectrlshiftdisplay,omitempty"`
7070
AppFocusFollowsCursor string `json:"app:focusfollowscursor,omitempty" jsonschema:"enum=off,enum=on,enum=term"`
71-
AppTabBar string `json:"app:tabbar,omitempty" jsonschema:"enum=top,enum=left"`
71+
AppTabBar string `json:"app:tabbar,omitempty" jsonschema:"enum=top,enum=left,enum=right"`
7272

7373
FeatureWaveAppBuilder bool `json:"feature:waveappbuilder,omitempty"`
7474

schema/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@
4747
"type": "string",
4848
"enum": [
4949
"top",
50-
"left"
50+
"left",
51+
"right"
5152
]
5253
},
5354
"feature:waveappbuilder": {

0 commit comments

Comments
 (0)