Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
205 changes: 205 additions & 0 deletions apps/desktop/src/main/body.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
currentTab: {
active: true,
pinned: false,
slotId: "slot-home",
type: "empty",
} as ({ type: string } & Record<string, unknown>) | null,
leftsidebar: {
expanded: true,
toggleExpanded: vi.fn(),
},
onPanelLayout: null as null | ((sizes: number[]) => void),
}));

vi.mock("@hypr/ui/components/ui/resizable", () => ({
ResizablePanelGroup: ({
autoSaveId,
children,
className,
direction,
onLayout,
}: {
autoSaveId?: string;
children: React.ReactNode;
className?: string;
direction: string;
onLayout?: (sizes: number[]) => void;
}) => {
mocks.onPanelLayout = onLayout ?? null;

return (
<div
data-auto-save-id={autoSaveId}
data-class-name={className}
data-direction={direction}
data-testid="panel-group"
>
{children}
</div>
);
},
ResizablePanel: ({
children,
className,
defaultSize,
maxSize,
minSize,
style,
}: {
children: React.ReactNode;
className?: string;
defaultSize?: number;
maxSize?: number;
minSize?: number;
style?: React.CSSProperties;
}) => (
<div
data-class-name={className}
data-default-size={defaultSize}
data-max-size={maxSize}
data-min-size={minSize}
data-max-width={style?.maxWidth}
data-min-width={style?.minWidth}
data-testid="panel"
>
{children}
</div>
),
ResizableHandle: ({ className }: { className?: string }) => (
<div data-class-name={className} data-testid="resize-handle" />
),
}));

vi.mock("~/contexts/shell", () => ({
useShell: () => ({
leftsidebar: mocks.leftsidebar,
}),
}));

vi.mock("~/store/zustand/tabs", () => ({
uniqueIdfromTab: (tab: { type: string }) => tab.type,
useTabs: (
selector: (state: { currentTab: typeof mocks.currentTab }) => unknown,
) => selector({ currentTab: mocks.currentTab }),
}));

vi.mock("./shell-sidebar", () => ({
ClassicMainSidebar: () =>
mocks.leftsidebar.expanded && mocks.currentTab?.type !== "onboarding" ? (
<aside data-testid="classic-main-sidebar" />
) : null,
}));

vi.mock("./tab-content", () => ({
ClassicMainTabContent: ({ tab }: { tab: { type: string } }) => (
<div data-tab-type={tab.type} data-testid="tab-content" />
),
}));

vi.mock("./update-banner", () => ({
SidebarTimelineUpdateButton: () => <button type="button">Update</button>,
useDesktopUpdateControl: () => ({ status: null, version: null }),
}));

vi.mock("./useTabsShortcuts", () => ({
useClassicMainTabsShortcuts: () => ({ runEscapeShortcut: vi.fn() }),
}));

vi.mock("~/session/components/bottom-accessory/global-live", () => ({
GlobalLiveTranscriptAccessory: ({
children,
}: {
children: React.ReactNode;
}) => <div data-testid="global-live-accessory">{children}</div>,
}));

vi.mock("~/shared/open-note-dialog", () => ({
useOpenNoteDialog: () => ({ open: vi.fn() }),
}));

vi.mock("~/shared/useNewNote", () => ({
useNewNote: () => vi.fn(),
}));

vi.mock("~/sidebar/timeline/upcoming-meeting", () => ({
useSidebarUpcomingMeetingStatus: () => null,
}));

import { ClassicMainBody } from "./body";

describe("ClassicMainBody", () => {
beforeEach(() => {
cleanup();
mocks.currentTab = {
active: true,
pinned: false,
slotId: "slot-home",
type: "empty",
};
mocks.leftsidebar.expanded = true;
mocks.leftsidebar.toggleExpanded.mockClear();
mocks.onPanelLayout = null;
});

it("wraps the expanded left sidebar in a persistent resizable panel", () => {
render(<ClassicMainBody />);

expect(screen.getByTestId("panel-group").dataset.direction).toBe(
"horizontal",
);
expect(screen.getByTestId("panel-group").dataset.autoSaveId).toBe(
"classic-main-sidebar",
);
expect(screen.getByTestId("classic-main-sidebar")).toBeTruthy();
expect(screen.getByTestId("resize-handle").dataset.className).toContain(
"after:w-2",
);

const panels = screen.getAllByTestId("panel");
expect(panels).toHaveLength(2);
expect(panels[0]?.dataset.defaultSize).toBe("18");
expect(panels[0]?.dataset.minSize).toBe("12");
expect(panels[0]?.dataset.maxSize).toBe("32");
expect(panels[0]?.dataset.minWidth).toBe("180");
expect(panels[0]?.dataset.maxWidth).toBe("360");

const sidebarChrome = document.querySelector<HTMLElement>(
"[data-left-sidebar-chrome]",
);

expect(sidebarChrome?.style.width).toBe("18%");
expect(sidebarChrome?.style.minWidth).toBe("180px");
expect(sidebarChrome?.style.maxWidth).toBe("360px");
expect(sidebarChrome?.className).not.toContain("w-[200px]");

act(() => {
mocks.onPanelLayout?.([24, 76]);
});

expect(sidebarChrome?.style.width).toBe("24%");
});

it("keeps the collapsed layout free of the sidebar resize handle", () => {
mocks.leftsidebar.expanded = false;

render(<ClassicMainBody />);

expect(screen.queryByTestId("classic-main-sidebar")).toBeNull();
expect(screen.queryByTestId("resize-handle")).toBeNull();
expect(screen.getAllByTestId("panel")).toHaveLength(1);
});

it("does not reserve a sidebar panel during onboarding", () => {
mocks.currentTab = { type: "onboarding" };

render(<ClassicMainBody />);

expect(screen.queryByTestId("classic-main-sidebar")).toBeNull();
expect(screen.queryByTestId("resize-handle")).toBeNull();
expect(screen.getAllByTestId("panel")).toHaveLength(1);
});
});
127 changes: 101 additions & 26 deletions apps/desktop/src/main/body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,21 @@ import {
SearchIcon,
SquarePenIcon,
} from "lucide-react";
import { type MouseEvent, type PointerEvent, useCallback, useRef } from "react";
import {
type CSSProperties,
type MouseEvent,
type PointerEvent,
useCallback,
useMemo,
useRef,
useState,
} from "react";

import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@hypr/ui/components/ui/resizable";
import { cn } from "@hypr/utils";

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

const MAIN_AREA_TOP_DRAG_HEIGHT_PX = 48;
const MAIN_AREA_WINDOW_DRAG_THRESHOLD_PX = 5;
const LEFT_SIDEBAR_DEFAULT_SIZE = 18;
const LEFT_SIDEBAR_MIN_SIZE = 12;
const LEFT_SIDEBAR_MAX_SIZE = 32;
const LEFT_SIDEBAR_MIN_WIDTH_PX = 180;
const LEFT_SIDEBAR_MAX_WIDTH_PX = 360;

type MainAreaWindowDragStart = {
pointerId: number;
Expand All @@ -46,6 +64,9 @@ export function ClassicMainBody() {
const { leftsidebar } = useShell();
const currentTab = useTabs((state) => state.currentTab);
const { runEscapeShortcut } = useClassicMainTabsShortcuts();
const [leftSidebarPanelSize, setLeftSidebarPanelSize] = useState(
LEFT_SIDEBAR_DEFAULT_SIZE,
);

const isOnboarding = currentTab?.type === "onboarding";
const isChangelog = currentTab?.type === "changelog";
Expand All @@ -54,6 +75,7 @@ export function ClassicMainBody() {
hasLeftSurfaceCustomSidebarTab(currentTab);
const showSidebarTimelineChrome = !hasCustomSidebar && !isOnboarding;
const showSidebarTimeline = showSidebarTimelineChrome && leftsidebar.expanded;
const showLeftSidebarPanel = leftsidebar.expanded && !isOnboarding;
Comment thread
cursor[bot] marked this conversation as resolved.
const showLeftSurfaceChromeBack = hasLeftSurfaceCustomSidebar;
const enableMainAreaTopDrag =
showSidebarTimelineChrome || hasLeftSurfaceCustomSidebar;
Expand All @@ -76,14 +98,38 @@ export function ClassicMainBody() {
const handleOpenNoteDialog = useCallback(() => {
openNoteDialog.open();
}, [openNoteDialog]);
const handlePanelLayout = useCallback(
(sizes: number[]) => {
if (!showLeftSidebarPanel) {
return;
}

const sidebarSize = sizes[0];
if (typeof sidebarSize === "number") {
setLeftSidebarPanelSize(sidebarSize);
}
},
[showLeftSidebarPanel],
);
const leftSidebarChromeStyle = useMemo(
() =>
({
width: `${leftSidebarPanelSize}%`,
minWidth: LEFT_SIDEBAR_MIN_WIDTH_PX,
maxWidth: LEFT_SIDEBAR_MAX_WIDTH_PX,
}) satisfies CSSProperties,
[leftSidebarPanelSize],
);

return (
<div className="relative flex h-full min-w-0 flex-1 flex-col">
{isOnboarding ? null : showSidebarTimelineChrome ? (
<div
data-tauri-drag-region
data-left-sidebar-chrome
style={leftSidebarChromeStyle}
className={cn([
"absolute top-0 z-40 h-12 w-[200px]",
"absolute top-0 z-40 h-12",
leftsidebar.expanded ? "left-0" : "left-1",
!leftsidebar.expanded && "pointer-events-none",
])}
Expand All @@ -105,7 +151,9 @@ export function ClassicMainBody() {
) : hasLeftSurfaceCustomSidebar ? (
<div
data-tauri-drag-region
className="absolute top-0 left-0 z-40 h-10 w-[200px]"
data-left-sidebar-chrome
style={leftSidebarChromeStyle}
className="absolute top-0 left-0 z-40 h-10"
/>
) : (
<div data-tauri-drag-region className="relative h-10 shrink-0">
Expand All @@ -118,7 +166,9 @@ export function ClassicMainBody() {
{showLeftSurfaceChromeBack ? (
<div
data-tauri-drag-region
className="absolute top-0 left-0 z-50 h-12 w-[200px]"
data-left-sidebar-chrome
style={leftSidebarChromeStyle}
className="absolute top-0 left-0 z-50 h-12"
>
<div
data-tauri-drag-region
Expand All @@ -133,29 +183,54 @@ export function ClassicMainBody() {
</div>
</div>
) : null}
<div className="flex min-h-0 min-w-0 flex-1 gap-1">
<ClassicMainSidebar />
<div
className="min-h-0 min-w-0 flex-1 overflow-auto"
onClickCapture={mainAreaTopDrag.onClickCapture}
onPointerCancel={mainAreaTopDrag.onPointerEnd}
onPointerDown={mainAreaTopDrag.onPointerDown}
onPointerMove={mainAreaTopDrag.onPointerMove}
onPointerUp={mainAreaTopDrag.onPointerEnd}
>
<GlobalLiveTranscriptAccessory
currentTab={currentTab}
surfaceChrome={mainSurfaceChrome}
<ResizablePanelGroup
autoSaveId={showLeftSidebarPanel ? "classic-main-sidebar" : undefined}
direction="horizontal"
className="min-h-0 flex-1 overflow-hidden"
onLayout={handlePanelLayout}
>
{showLeftSidebarPanel ? (
<>
<ResizablePanel
defaultSize={LEFT_SIDEBAR_DEFAULT_SIZE}
minSize={LEFT_SIDEBAR_MIN_SIZE}
maxSize={LEFT_SIDEBAR_MAX_SIZE}
className="min-h-0 overflow-hidden"
style={{
minWidth: LEFT_SIDEBAR_MIN_WIDTH_PX,
maxWidth: LEFT_SIDEBAR_MAX_WIDTH_PX,
}}
>
<ClassicMainSidebar />
</ResizablePanel>
<ResizableHandle className="z-10 w-1 !bg-transparent after:w-2" />
</>
) : (
<ClassicMainSidebar />
)}
<ResizablePanel className="min-h-0 flex-1 overflow-hidden">
<div
className="h-full min-h-0 min-w-0 flex-1 overflow-auto"
onClickCapture={mainAreaTopDrag.onClickCapture}
onPointerCancel={mainAreaTopDrag.onPointerEnd}
onPointerDown={mainAreaTopDrag.onPointerDown}
onPointerMove={mainAreaTopDrag.onPointerMove}
onPointerUp={mainAreaTopDrag.onPointerEnd}
>
{currentTab ? (
<ClassicMainTabContent
key={uniqueIdfromTab(currentTab)}
tab={currentTab as Tab}
/>
) : null}
</GlobalLiveTranscriptAccessory>
</div>
</div>
<GlobalLiveTranscriptAccessory
currentTab={currentTab}
surfaceChrome={mainSurfaceChrome}
>
{currentTab ? (
<ClassicMainTabContent
key={uniqueIdfromTab(currentTab)}
tab={currentTab as Tab}
/>
) : null}
</GlobalLiveTranscriptAccessory>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
Expand Down
Loading
Loading