Skip to content

Commit 0ff3aad

Browse files
Make left sidebar resizable (#5645)
Wrap the desktop left sidebar in a persistent resizable panel and cover expanded/collapsed layout states with tests.
1 parent 890a35e commit 0ff3aad

4 files changed

Lines changed: 311 additions & 33 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { act, cleanup, render, screen } from "@testing-library/react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const mocks = vi.hoisted(() => ({
5+
currentTab: {
6+
active: true,
7+
pinned: false,
8+
slotId: "slot-home",
9+
type: "empty",
10+
} as ({ type: string } & Record<string, unknown>) | null,
11+
leftsidebar: {
12+
expanded: true,
13+
toggleExpanded: vi.fn(),
14+
},
15+
onPanelLayout: null as null | ((sizes: number[]) => void),
16+
}));
17+
18+
vi.mock("@hypr/ui/components/ui/resizable", () => ({
19+
ResizablePanelGroup: ({
20+
autoSaveId,
21+
children,
22+
className,
23+
direction,
24+
onLayout,
25+
}: {
26+
autoSaveId?: string;
27+
children: React.ReactNode;
28+
className?: string;
29+
direction: string;
30+
onLayout?: (sizes: number[]) => void;
31+
}) => {
32+
mocks.onPanelLayout = onLayout ?? null;
33+
34+
return (
35+
<div
36+
data-auto-save-id={autoSaveId}
37+
data-class-name={className}
38+
data-direction={direction}
39+
data-testid="panel-group"
40+
>
41+
{children}
42+
</div>
43+
);
44+
},
45+
ResizablePanel: ({
46+
children,
47+
className,
48+
defaultSize,
49+
maxSize,
50+
minSize,
51+
style,
52+
}: {
53+
children: React.ReactNode;
54+
className?: string;
55+
defaultSize?: number;
56+
maxSize?: number;
57+
minSize?: number;
58+
style?: React.CSSProperties;
59+
}) => (
60+
<div
61+
data-class-name={className}
62+
data-default-size={defaultSize}
63+
data-max-size={maxSize}
64+
data-min-size={minSize}
65+
data-max-width={style?.maxWidth}
66+
data-min-width={style?.minWidth}
67+
data-testid="panel"
68+
>
69+
{children}
70+
</div>
71+
),
72+
ResizableHandle: ({ className }: { className?: string }) => (
73+
<div data-class-name={className} data-testid="resize-handle" />
74+
),
75+
}));
76+
77+
vi.mock("~/contexts/shell", () => ({
78+
useShell: () => ({
79+
leftsidebar: mocks.leftsidebar,
80+
}),
81+
}));
82+
83+
vi.mock("~/store/zustand/tabs", () => ({
84+
uniqueIdfromTab: (tab: { type: string }) => tab.type,
85+
useTabs: (
86+
selector: (state: { currentTab: typeof mocks.currentTab }) => unknown,
87+
) => selector({ currentTab: mocks.currentTab }),
88+
}));
89+
90+
vi.mock("./shell-sidebar", () => ({
91+
ClassicMainSidebar: () =>
92+
mocks.leftsidebar.expanded && mocks.currentTab?.type !== "onboarding" ? (
93+
<aside data-testid="classic-main-sidebar" />
94+
) : null,
95+
}));
96+
97+
vi.mock("./tab-content", () => ({
98+
ClassicMainTabContent: ({ tab }: { tab: { type: string } }) => (
99+
<div data-tab-type={tab.type} data-testid="tab-content" />
100+
),
101+
}));
102+
103+
vi.mock("./update-banner", () => ({
104+
SidebarTimelineUpdateButton: () => <button type="button">Update</button>,
105+
useDesktopUpdateControl: () => ({ status: null, version: null }),
106+
}));
107+
108+
vi.mock("./useTabsShortcuts", () => ({
109+
useClassicMainTabsShortcuts: () => ({ runEscapeShortcut: vi.fn() }),
110+
}));
111+
112+
vi.mock("~/session/components/bottom-accessory/global-live", () => ({
113+
GlobalLiveTranscriptAccessory: ({
114+
children,
115+
}: {
116+
children: React.ReactNode;
117+
}) => <div data-testid="global-live-accessory">{children}</div>,
118+
}));
119+
120+
vi.mock("~/shared/open-note-dialog", () => ({
121+
useOpenNoteDialog: () => ({ open: vi.fn() }),
122+
}));
123+
124+
vi.mock("~/shared/useNewNote", () => ({
125+
useNewNote: () => vi.fn(),
126+
}));
127+
128+
vi.mock("~/sidebar/timeline/upcoming-meeting", () => ({
129+
useSidebarUpcomingMeetingStatus: () => null,
130+
}));
131+
132+
import { ClassicMainBody } from "./body";
133+
134+
describe("ClassicMainBody", () => {
135+
beforeEach(() => {
136+
cleanup();
137+
mocks.currentTab = {
138+
active: true,
139+
pinned: false,
140+
slotId: "slot-home",
141+
type: "empty",
142+
};
143+
mocks.leftsidebar.expanded = true;
144+
mocks.leftsidebar.toggleExpanded.mockClear();
145+
mocks.onPanelLayout = null;
146+
});
147+
148+
it("wraps the expanded left sidebar in a persistent resizable panel", () => {
149+
render(<ClassicMainBody />);
150+
151+
expect(screen.getByTestId("panel-group").dataset.direction).toBe(
152+
"horizontal",
153+
);
154+
expect(screen.getByTestId("panel-group").dataset.autoSaveId).toBe(
155+
"classic-main-sidebar",
156+
);
157+
expect(screen.getByTestId("classic-main-sidebar")).toBeTruthy();
158+
expect(screen.getByTestId("resize-handle").dataset.className).toContain(
159+
"after:w-2",
160+
);
161+
162+
const panels = screen.getAllByTestId("panel");
163+
expect(panels).toHaveLength(2);
164+
expect(panels[0]?.dataset.defaultSize).toBe("18");
165+
expect(panels[0]?.dataset.minSize).toBe("12");
166+
expect(panels[0]?.dataset.maxSize).toBe("32");
167+
expect(panels[0]?.dataset.minWidth).toBe("180");
168+
expect(panels[0]?.dataset.maxWidth).toBe("360");
169+
170+
const sidebarChrome = document.querySelector<HTMLElement>(
171+
"[data-left-sidebar-chrome]",
172+
);
173+
174+
expect(sidebarChrome?.style.width).toBe("18%");
175+
expect(sidebarChrome?.style.minWidth).toBe("180px");
176+
expect(sidebarChrome?.style.maxWidth).toBe("360px");
177+
expect(sidebarChrome?.className).not.toContain("w-[200px]");
178+
179+
act(() => {
180+
mocks.onPanelLayout?.([24, 76]);
181+
});
182+
183+
expect(sidebarChrome?.style.width).toBe("24%");
184+
});
185+
186+
it("keeps the collapsed layout free of the sidebar resize handle", () => {
187+
mocks.leftsidebar.expanded = false;
188+
189+
render(<ClassicMainBody />);
190+
191+
expect(screen.queryByTestId("classic-main-sidebar")).toBeNull();
192+
expect(screen.queryByTestId("resize-handle")).toBeNull();
193+
expect(screen.getAllByTestId("panel")).toHaveLength(1);
194+
});
195+
196+
it("does not reserve a sidebar panel during onboarding", () => {
197+
mocks.currentTab = { type: "onboarding" };
198+
199+
render(<ClassicMainBody />);
200+
201+
expect(screen.queryByTestId("classic-main-sidebar")).toBeNull();
202+
expect(screen.queryByTestId("resize-handle")).toBeNull();
203+
expect(screen.getAllByTestId("panel")).toHaveLength(1);
204+
});
205+
});

apps/desktop/src/main/body.tsx

Lines changed: 101 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,21 @@ import {
77
SearchIcon,
88
SquarePenIcon,
99
} from "lucide-react";
10-
import { type MouseEvent, type PointerEvent, useCallback, useRef } from "react";
10+
import {
11+
type CSSProperties,
12+
type MouseEvent,
13+
type PointerEvent,
14+
useCallback,
15+
useMemo,
16+
useRef,
17+
useState,
18+
} from "react";
1119

20+
import {
21+
ResizableHandle,
22+
ResizablePanel,
23+
ResizablePanelGroup,
24+
} from "@hypr/ui/components/ui/resizable";
1225
import { cn } from "@hypr/utils";
1326

1427
import { resolveMainSurfaceChrome } from "./main-surface-chrome";
@@ -34,6 +47,11 @@ import { type Tab, uniqueIdfromTab, useTabs } from "~/store/zustand/tabs";
3447

3548
const MAIN_AREA_TOP_DRAG_HEIGHT_PX = 48;
3649
const MAIN_AREA_WINDOW_DRAG_THRESHOLD_PX = 5;
50+
const LEFT_SIDEBAR_DEFAULT_SIZE = 18;
51+
const LEFT_SIDEBAR_MIN_SIZE = 12;
52+
const LEFT_SIDEBAR_MAX_SIZE = 32;
53+
const LEFT_SIDEBAR_MIN_WIDTH_PX = 180;
54+
const LEFT_SIDEBAR_MAX_WIDTH_PX = 360;
3755

3856
type MainAreaWindowDragStart = {
3957
pointerId: number;
@@ -46,6 +64,9 @@ export function ClassicMainBody() {
4664
const { leftsidebar } = useShell();
4765
const currentTab = useTabs((state) => state.currentTab);
4866
const { runEscapeShortcut } = useClassicMainTabsShortcuts();
67+
const [leftSidebarPanelSize, setLeftSidebarPanelSize] = useState(
68+
LEFT_SIDEBAR_DEFAULT_SIZE,
69+
);
4970

5071
const isOnboarding = currentTab?.type === "onboarding";
5172
const isChangelog = currentTab?.type === "changelog";
@@ -54,6 +75,7 @@ export function ClassicMainBody() {
5475
hasLeftSurfaceCustomSidebarTab(currentTab);
5576
const showSidebarTimelineChrome = !hasCustomSidebar && !isOnboarding;
5677
const showSidebarTimeline = showSidebarTimelineChrome && leftsidebar.expanded;
78+
const showLeftSidebarPanel = leftsidebar.expanded && !isOnboarding;
5779
const showLeftSurfaceChromeBack = hasLeftSurfaceCustomSidebar;
5880
const enableMainAreaTopDrag =
5981
showSidebarTimelineChrome || hasLeftSurfaceCustomSidebar;
@@ -76,14 +98,38 @@ export function ClassicMainBody() {
7698
const handleOpenNoteDialog = useCallback(() => {
7799
openNoteDialog.open();
78100
}, [openNoteDialog]);
101+
const handlePanelLayout = useCallback(
102+
(sizes: number[]) => {
103+
if (!showLeftSidebarPanel) {
104+
return;
105+
}
106+
107+
const sidebarSize = sizes[0];
108+
if (typeof sidebarSize === "number") {
109+
setLeftSidebarPanelSize(sidebarSize);
110+
}
111+
},
112+
[showLeftSidebarPanel],
113+
);
114+
const leftSidebarChromeStyle = useMemo(
115+
() =>
116+
({
117+
width: `${leftSidebarPanelSize}%`,
118+
minWidth: LEFT_SIDEBAR_MIN_WIDTH_PX,
119+
maxWidth: LEFT_SIDEBAR_MAX_WIDTH_PX,
120+
}) satisfies CSSProperties,
121+
[leftSidebarPanelSize],
122+
);
79123

80124
return (
81125
<div className="relative flex h-full min-w-0 flex-1 flex-col">
82126
{isOnboarding ? null : showSidebarTimelineChrome ? (
83127
<div
84128
data-tauri-drag-region
129+
data-left-sidebar-chrome
130+
style={leftSidebarChromeStyle}
85131
className={cn([
86-
"absolute top-0 z-40 h-12 w-[200px]",
132+
"absolute top-0 z-40 h-12",
87133
leftsidebar.expanded ? "left-0" : "left-1",
88134
!leftsidebar.expanded && "pointer-events-none",
89135
])}
@@ -105,7 +151,9 @@ export function ClassicMainBody() {
105151
) : hasLeftSurfaceCustomSidebar ? (
106152
<div
107153
data-tauri-drag-region
108-
className="absolute top-0 left-0 z-40 h-10 w-[200px]"
154+
data-left-sidebar-chrome
155+
style={leftSidebarChromeStyle}
156+
className="absolute top-0 left-0 z-40 h-10"
109157
/>
110158
) : (
111159
<div data-tauri-drag-region className="relative h-10 shrink-0">
@@ -118,7 +166,9 @@ export function ClassicMainBody() {
118166
{showLeftSurfaceChromeBack ? (
119167
<div
120168
data-tauri-drag-region
121-
className="absolute top-0 left-0 z-50 h-12 w-[200px]"
169+
data-left-sidebar-chrome
170+
style={leftSidebarChromeStyle}
171+
className="absolute top-0 left-0 z-50 h-12"
122172
>
123173
<div
124174
data-tauri-drag-region
@@ -133,29 +183,54 @@ export function ClassicMainBody() {
133183
</div>
134184
</div>
135185
) : null}
136-
<div className="flex min-h-0 min-w-0 flex-1 gap-1">
137-
<ClassicMainSidebar />
138-
<div
139-
className="min-h-0 min-w-0 flex-1 overflow-auto"
140-
onClickCapture={mainAreaTopDrag.onClickCapture}
141-
onPointerCancel={mainAreaTopDrag.onPointerEnd}
142-
onPointerDown={mainAreaTopDrag.onPointerDown}
143-
onPointerMove={mainAreaTopDrag.onPointerMove}
144-
onPointerUp={mainAreaTopDrag.onPointerEnd}
145-
>
146-
<GlobalLiveTranscriptAccessory
147-
currentTab={currentTab}
148-
surfaceChrome={mainSurfaceChrome}
186+
<ResizablePanelGroup
187+
autoSaveId={showLeftSidebarPanel ? "classic-main-sidebar" : undefined}
188+
direction="horizontal"
189+
className="min-h-0 flex-1 overflow-hidden"
190+
onLayout={handlePanelLayout}
191+
>
192+
{showLeftSidebarPanel ? (
193+
<>
194+
<ResizablePanel
195+
defaultSize={LEFT_SIDEBAR_DEFAULT_SIZE}
196+
minSize={LEFT_SIDEBAR_MIN_SIZE}
197+
maxSize={LEFT_SIDEBAR_MAX_SIZE}
198+
className="min-h-0 overflow-hidden"
199+
style={{
200+
minWidth: LEFT_SIDEBAR_MIN_WIDTH_PX,
201+
maxWidth: LEFT_SIDEBAR_MAX_WIDTH_PX,
202+
}}
203+
>
204+
<ClassicMainSidebar />
205+
</ResizablePanel>
206+
<ResizableHandle className="z-10 w-1 !bg-transparent after:w-2" />
207+
</>
208+
) : (
209+
<ClassicMainSidebar />
210+
)}
211+
<ResizablePanel className="min-h-0 flex-1 overflow-hidden">
212+
<div
213+
className="h-full min-h-0 min-w-0 flex-1 overflow-auto"
214+
onClickCapture={mainAreaTopDrag.onClickCapture}
215+
onPointerCancel={mainAreaTopDrag.onPointerEnd}
216+
onPointerDown={mainAreaTopDrag.onPointerDown}
217+
onPointerMove={mainAreaTopDrag.onPointerMove}
218+
onPointerUp={mainAreaTopDrag.onPointerEnd}
149219
>
150-
{currentTab ? (
151-
<ClassicMainTabContent
152-
key={uniqueIdfromTab(currentTab)}
153-
tab={currentTab as Tab}
154-
/>
155-
) : null}
156-
</GlobalLiveTranscriptAccessory>
157-
</div>
158-
</div>
220+
<GlobalLiveTranscriptAccessory
221+
currentTab={currentTab}
222+
surfaceChrome={mainSurfaceChrome}
223+
>
224+
{currentTab ? (
225+
<ClassicMainTabContent
226+
key={uniqueIdfromTab(currentTab)}
227+
tab={currentTab as Tab}
228+
/>
229+
) : null}
230+
</GlobalLiveTranscriptAccessory>
231+
</div>
232+
</ResizablePanel>
233+
</ResizablePanelGroup>
159234
</div>
160235
);
161236
}

0 commit comments

Comments
 (0)