Skip to content

Commit ac6b2f3

Browse files
authored
New Vertical Tab Bar Option (#3059)
Lots of work on the vtabbar UI / UX to make it work and integrate into the Wave UI Lots of work on the workspace-layout-model to handle *two* resizable panels.
1 parent f151313 commit ac6b2f3

32 files changed

+1032
-414
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ out/
1616
make/
1717
artifacts/
1818
mikework/
19+
aiplans/
1920
manifests/
2021
.env
2122
out

Taskfile.yml

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,18 @@ tasks:
282282
- cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wsh*"
283283
platforms: [windows]
284284
ignore_error: true
285+
- task: build:wsh:parallel
286+
deps:
287+
- go:mod:tidy
288+
- generate
289+
sources:
290+
- "cmd/wsh/**/*.go"
291+
- "pkg/**/*.go"
292+
generates:
293+
- "dist/bin/wsh*"
294+
295+
build:wsh:parallel:
296+
deps:
285297
- task: build:wsh:internal
286298
vars:
287299
GOOS: darwin
@@ -314,14 +326,7 @@ tasks:
314326
vars:
315327
GOOS: windows
316328
GOARCH: arm64
317-
deps:
318-
- go:mod:tidy
319-
- generate
320-
sources:
321-
- "cmd/wsh/**/*.go"
322-
- "pkg/**/*.go"
323-
generates:
324-
- "dist/bin/wsh*"
329+
internal: true
325330

326331
build:wsh:internal:
327332
vars:

cmd/wsh/cmd/wshcmd-root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2025, Command Line Inc.
1+
// Copyright 2026, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

44
package cmd

docs/docs/config.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ wsh editconfig
4444
| app:disablectrlshiftarrows <VersionBadge version="v0.14" /> | bool | Set to true to disable Ctrl+Shift block-navigation keybindings (`Arrow` and `h/j/k/l`) (defaults to false) |
4545
| app:disablectrlshiftdisplay <VersionBadge version="v0.14" /> | bool | Set to true to disable the Ctrl+Shift visual indicator display (defaults to false) |
4646
| app:focusfollowscursor <VersionBadge version="v0.14" /> | string | Controls whether block focus follows cursor movement: `"off"` (default), `"on"` (all blocks), or `"term"` (terminal blocks only) |
47+
| app:tabbar <VersionBadge version="v0.14.4" /> | 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 |
4748
| ai:preset | string | the default AI preset to use |
4849
| ai:baseurl | string | Set the AI Base Url (must be OpenAI compatible) |
4950
| ai:apitoken | string | your AI api token |

frontend/app/aipanel/aipanel.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,11 @@ const ConfigChangeModeFixer = memo(() => {
245245

246246
ConfigChangeModeFixer.displayName = "ConfigChangeModeFixer";
247247

248-
const AIPanelComponentInner = memo(() => {
248+
type AIPanelComponentInnerProps = {
249+
roundTopLeft: boolean;
250+
};
251+
252+
const AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps) => {
249253
const [isDragOver, setIsDragOver] = useState(false);
250254
const [isReactDndDragOver, setIsReactDndDragOver] = useState(false);
251255
const [initialLoadDone, setInitialLoadDone] = useState(false);
@@ -554,6 +558,7 @@ const AIPanelComponentInner = memo(() => {
554558
isFocused ? "border-2 border-accent" : "border-2 border-transparent"
555559
)}
556560
style={{
561+
borderTopLeftRadius: roundTopLeft ? 10 : 0,
557562
borderTopRightRadius: model.inBuilder ? 0 : 10,
558563
borderBottomRightRadius: model.inBuilder ? 0 : 10,
559564
borderBottomLeftRadius: 10,
@@ -607,10 +612,14 @@ const AIPanelComponentInner = memo(() => {
607612

608613
AIPanelComponentInner.displayName = "AIPanelInner";
609614

610-
const AIPanelComponent = () => {
615+
type AIPanelComponentProps = {
616+
roundTopLeft: boolean;
617+
};
618+
619+
const AIPanelComponent = ({ roundTopLeft }: AIPanelComponentProps) => {
611620
return (
612621
<ErrorBoundary>
613-
<AIPanelComponentInner />
622+
<AIPanelComponentInner roundTopLeft={roundTopLeft} />
614623
</ErrorBoundary>
615624
);
616625
};

frontend/app/store/wshclientapi.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,12 @@ export class RpcApiType {
618618
return client.wshRpcCall("listalleditableapps", null, opts);
619619
}
620620

621+
// command "macosversion" [call]
622+
MacOSVersionCommand(client: WshClient, opts?: RpcOpts): Promise<string> {
623+
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "macosversion", null, opts);
624+
return client.wshRpcCall("macosversion", null, opts);
625+
}
626+
621627
// command "makedraftfromlocal" [call]
622628
MakeDraftFromLocalCommand(client: WshClient, data: CommandMakeDraftFromLocalData, opts?: RpcOpts): Promise<CommandMakeDraftFromLocalRtnData> {
623629
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "makedraftfromlocal", data, opts);

frontend/app/tab/tab.tsx

Lines changed: 5 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { getTabBadgeAtom } from "@/app/store/badge";
5-
import { getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global";
5+
import { refocusNode } from "@/app/store/global";
66
import { TabRpcClient } from "@/app/store/wshrpcutil";
77
import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv";
88
import { Button } from "@/element/button";
@@ -14,17 +14,20 @@ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef,
1414
import { makeORef } from "../store/wos";
1515
import { TabBadges } from "./tabbadges";
1616
import "./tab.scss";
17+
import { buildTabContextMenu } from "./tabcontextmenu";
1718

18-
type TabEnv = WaveEnvSubset<{
19+
export type TabEnv = WaveEnvSubset<{
1920
rpc: {
2021
ActivityCommand: WaveEnv["rpc"]["ActivityCommand"];
22+
SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"];
2123
SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"];
2224
UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"];
2325
};
2426
atoms: {
2527
fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"];
2628
};
2729
wos: WaveEnv["wos"];
30+
getSettingsKeyAtom: WaveEnv["getSettingsKeyAtom"];
2831
showContextMenu: WaveEnv["showContextMenu"];
2932
}>;
3033

@@ -216,88 +219,6 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
216219

217220
TabV.displayName = "TabV";
218221

219-
const FlagColors: { label: string; value: string }[] = [
220-
{ label: "Green", value: "#58C142" },
221-
{ label: "Teal", value: "#00FFDB" },
222-
{ label: "Blue", value: "#429DFF" },
223-
{ label: "Purple", value: "#BF55EC" },
224-
{ label: "Red", value: "#FF453A" },
225-
{ label: "Orange", value: "#FF9500" },
226-
{ label: "Yellow", value: "#FFE900" },
227-
];
228-
229-
function buildTabContextMenu(
230-
id: string,
231-
renameRef: React.RefObject<(() => void) | null>,
232-
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void,
233-
env: TabEnv
234-
): ContextMenuItem[] {
235-
const menu: ContextMenuItem[] = [];
236-
menu.push(
237-
{ label: "Rename Tab", click: () => renameRef.current?.() },
238-
{
239-
label: "Copy TabId",
240-
click: () => fireAndForget(() => navigator.clipboard.writeText(id)),
241-
},
242-
{ type: "separator" }
243-
);
244-
const tabORef = makeORef("tab", id);
245-
const currentFlagColor = globalStore.get(getOrefMetaKeyAtom(tabORef, "tab:flagcolor")) ?? null;
246-
const flagSubmenu: ContextMenuItem[] = [
247-
{
248-
label: "None",
249-
type: "checkbox",
250-
checked: currentFlagColor == null,
251-
click: () =>
252-
fireAndForget(() =>
253-
env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": null } })
254-
),
255-
},
256-
...FlagColors.map((fc) => ({
257-
label: fc.label,
258-
type: "checkbox" as const,
259-
checked: currentFlagColor === fc.value,
260-
click: () =>
261-
fireAndForget(() =>
262-
env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": fc.value } })
263-
),
264-
})),
265-
];
266-
menu.push({ label: "Flag Tab", type: "submenu", submenu: flagSubmenu }, { type: "separator" });
267-
const fullConfig = globalStore.get(env.atoms.fullConfigAtom);
268-
const bgPresets: string[] = [];
269-
for (const key in fullConfig?.presets ?? {}) {
270-
if (key.startsWith("bg@") && fullConfig.presets[key] != null) {
271-
bgPresets.push(key);
272-
}
273-
}
274-
bgPresets.sort((a, b) => {
275-
const aOrder = fullConfig.presets[a]["display:order"] ?? 0;
276-
const bOrder = fullConfig.presets[b]["display:order"] ?? 0;
277-
return aOrder - bOrder;
278-
});
279-
if (bgPresets.length > 0) {
280-
const submenu: ContextMenuItem[] = [];
281-
const oref = makeORef("tab", id);
282-
for (const presetName of bgPresets) {
283-
// preset cannot be null (filtered above)
284-
const preset = fullConfig.presets[presetName];
285-
submenu.push({
286-
label: preset["display:name"] ?? presetName,
287-
click: () =>
288-
fireAndForget(async () => {
289-
await env.rpc.SetMetaCommand(TabRpcClient, { oref, meta: preset });
290-
env.rpc.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });
291-
recordTEvent("action:settabtheme");
292-
}),
293-
});
294-
}
295-
menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
296-
}
297-
menu.push({ label: "Close Tab", click: () => onClose(null) });
298-
return menu;
299-
}
300-
301222
interface TabProps {
302223
id: string;
303224
active: boolean;

frontend/app/tab/tabbar.tsx

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil";
77
import { useWaveEnv } from "@/app/waveenv/waveenv";
88
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
99
import { deleteLayoutModelForTab } from "@/layout/index";
10+
import { isMacOSTahoeOrLater } from "@/util/platformutil";
1011
import { fireAndForget } from "@/util/util";
1112
import { useAtomValue } from "jotai";
1213
import { OverlayScrollbars } from "overlayscrollbars";
@@ -20,6 +21,9 @@ import { WorkspaceSwitcher } from "./workspaceswitcher";
2021

2122
const TabDefaultWidth = 130;
2223
const TabMinWidth = 100;
24+
const MacOSTrafficLightsWidth = 74;
25+
const MacOSTahoeTrafficLightsWidth = 80;
26+
2327
const OSOptions = {
2428
overflow: {
2529
x: "scroll",
@@ -39,6 +43,7 @@ const OSOptions = {
3943

4044
interface TabBarProps {
4145
workspace: Workspace;
46+
noTabs?: boolean;
4247
}
4348

4449
const WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject<HTMLDivElement> }) => {
@@ -152,7 +157,7 @@ function strArrayIsEqual(a: string[], b: string[]) {
152157
return true;
153158
}
154159

155-
const TabBar = memo(({ workspace }: TabBarProps) => {
160+
const TabBar = memo(({ workspace, noTabs }: TabBarProps) => {
156161
const env = useWaveEnv<TabBarEnv>();
157162
const [tabIds, setTabIds] = useState<string[]>([]);
158163
const [dragStartPositions, setDragStartPositions] = useState<number[]>([]);
@@ -635,10 +640,13 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
635640
// Calculate window drag left width based on platform and state
636641
let windowDragLeftWidth = 10;
637642
if (env.isMacOS() && !isFullScreen) {
643+
const trafficLightsWidth = isMacOSTahoeOrLater()
644+
? MacOSTahoeTrafficLightsWidth
645+
: MacOSTrafficLightsWidth;
638646
if (zoomFactor > 0) {
639-
windowDragLeftWidth = 74 / zoomFactor;
647+
windowDragLeftWidth = trafficLightsWidth / zoomFactor;
640648
} else {
641-
windowDragLeftWidth = 74;
649+
windowDragLeftWidth = trafficLightsWidth;
642650
}
643651
}
644652

@@ -680,33 +688,41 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
680688
<WorkspaceSwitcher />
681689
</Tooltip>
682690
<div className="tab-bar" ref={tabBarRef} data-overlayscrollbars-initialize>
683-
<div className="tabs-wrapper" ref={tabsWrapperRef} style={{ width: `${tabsWrapperWidth}px` }}>
684-
{tabIds.map((tabId, index) => {
685-
const isActive = activeTabId === tabId;
686-
const showDivider = index !== 0 && !isActive && index !== activeTabIndex + 1;
687-
return (
688-
<Tab
689-
key={tabId}
690-
ref={tabRefs.current[index]}
691-
id={tabId}
692-
showDivider={showDivider}
693-
onSelect={() => handleSelectTab(tabId)}
694-
active={isActive}
695-
onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])}
696-
onClose={(event) => handleCloseTab(event, tabId)}
697-
onLoaded={() => handleTabLoaded(tabId)}
698-
isDragging={draggingTab === tabId}
699-
tabWidth={tabWidthRef.current}
700-
isNew={tabId === newTabId}
701-
/>
702-
);
703-
})}
691+
<div
692+
className="tabs-wrapper"
693+
ref={tabsWrapperRef}
694+
style={{
695+
width: noTabs ? 0 : tabsWrapperWidth,
696+
...(noTabs ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : {}),
697+
}}
698+
>
699+
{!noTabs &&
700+
tabIds.map((tabId, index) => {
701+
const isActive = activeTabId === tabId;
702+
const showDivider = index !== 0 && !isActive && index !== activeTabIndex + 1;
703+
return (
704+
<Tab
705+
key={tabId}
706+
ref={tabRefs.current[index]}
707+
id={tabId}
708+
showDivider={showDivider}
709+
onSelect={() => handleSelectTab(tabId)}
710+
active={isActive}
711+
onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])}
712+
onClose={(event) => handleCloseTab(event, tabId)}
713+
onLoaded={() => handleTabLoaded(tabId)}
714+
isDragging={draggingTab === tabId}
715+
tabWidth={tabWidthRef.current}
716+
isNew={tabId === newTabId}
717+
/>
718+
);
719+
})}
704720
</div>
705721
</div>
706722
<button
707723
ref={addBtnRef}
708724
title="Add Tab"
709-
className="flex h-[22px] px-2 mb-1 mx-1 items-center rounded-md box-border cursor-pointer hover:bg-hoverbg transition-colors text-[12px] text-secondary hover:text-primary"
725+
className={`flex h-[22px] px-2 mb-1 mx-1 items-center rounded-md box-border cursor-pointer hover:bg-hoverbg transition-colors text-[12px] text-secondary hover:text-primary${noTabs ? " invisible" : ""}`}
710726
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
711727
onClick={handleAddTab}
712728
>

frontend/app/tab/tabbarenv.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export type TabBarEnv = WaveEnvSubset<{
1212
installAppUpdate: WaveEnv["electron"]["installAppUpdate"];
1313
};
1414
rpc: {
15+
ActivityCommand: WaveEnv["rpc"]["ActivityCommand"];
16+
SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"];
17+
SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"];
18+
UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"];
1519
UpdateWorkspaceTabIdsCommand: WaveEnv["rpc"]["UpdateWorkspaceTabIdsCommand"];
1620
};
1721
atoms: {
@@ -24,7 +28,8 @@ export type TabBarEnv = WaveEnvSubset<{
2428
updaterStatusAtom: WaveEnv["atoms"]["updaterStatusAtom"];
2529
};
2630
wos: WaveEnv["wos"];
27-
getSettingsKeyAtom: SettingsKeyAtomFnType<"app:hideaibutton" | "tab:confirmclose" | "window:showmenubar">;
31+
getSettingsKeyAtom: SettingsKeyAtomFnType<"app:hideaibutton" | "app:tabbar" | "tab:confirmclose" | "window:showmenubar">;
32+
showContextMenu: WaveEnv["showContextMenu"];
2833
mockSetWaveObj: WaveEnv["mockSetWaveObj"];
2934
isWindows: WaveEnv["isWindows"];
3035
isMacOS: WaveEnv["isMacOS"];

frontend/app/tab/tabcontent.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const tileGapSizeAtom = atom((get) => {
1717
return settings["window:tilegapsize"];
1818
});
1919

20-
const TabContent = React.memo(({ tabId }: { tabId: string }) => {
20+
const TabContent = React.memo(({ tabId, noTopPadding }: { tabId: string; noTopPadding?: boolean }) => {
2121
const oref = useMemo(() => WOS.makeORef("tab", tabId), [tabId]);
2222
const loadingAtom = useMemo(() => WOS.getWaveObjectLoadingAtom(oref), [oref]);
2323
const tabLoading = useAtomValue(loadingAtom);
@@ -67,7 +67,7 @@ const TabContent = React.memo(({ tabId }: { tabId: string }) => {
6767
}
6868

6969
return (
70-
<div className="flex flex-row flex-grow min-h-0 w-full items-center justify-center overflow-hidden relative pt-[3px] pr-[3px]">
70+
<div className={`flex flex-row flex-grow min-h-0 w-full items-center justify-center overflow-hidden relative ${noTopPadding ? "" : "pt-[3px]"} pr-[3px]`}>
7171
{innerContent}
7272
</div>
7373
);

0 commit comments

Comments
 (0)