Skip to content

Commit ad518c1

Browse files
authored
Refactor sidebar layout to isolate renders (#278)
1 parent 307e30b commit ad518c1

7 files changed

Lines changed: 138 additions & 45 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { OperatorChatSidebarProps } from "./types/operator-chat-sidebar.types";
2+
3+
export function areOperatorChatSidebarPropsEqual(
4+
previous: OperatorChatSidebarProps,
5+
next: OperatorChatSidebarProps,
6+
): boolean {
7+
return (
8+
previous.activeSessionId === next.activeSessionId &&
9+
previous.isMobileOpen === next.isMobileOpen &&
10+
previous.onCloseMobileSidebar === next.onCloseMobileSidebar &&
11+
previous.onSearch === next.onSearch
12+
);
13+
}

packages/web/src/components/web-shell/operator-chat-sidebar.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { useRouter } from "next/navigation";
4-
import { type ReactElement, useState } from "react";
4+
import { type ReactElement, memo, useState } from "react";
55
import { toast } from "sonner";
66

77
import { ChatRoomSidebar } from "@/components/chat-room/chat-room-sidebar";
@@ -15,19 +15,17 @@ import { useCurrentWorkspaceQuery } from "@/lib/api/queries";
1515
import { useWorkspaceProjectsQuery } from "@/lib/api/realtime-queries";
1616
import { useRealtimeStore } from "@/lib/realtime";
1717

18+
import { areOperatorChatSidebarPropsEqual } from "./operator-chat-sidebar-render-utils";
19+
import type { OperatorChatSidebarProps } from "./types/operator-chat-sidebar.types";
20+
1821
const NO_REFETCH = { refetchIntervalMs: false } as const;
1922

20-
export function OperatorChatSidebar({
23+
function OperatorChatSidebarView({
2124
activeSessionId,
2225
isMobileOpen,
2326
onCloseMobileSidebar,
2427
onSearch,
25-
}: {
26-
activeSessionId: string;
27-
isMobileOpen: boolean;
28-
onCloseMobileSidebar: () => void;
29-
onSearch: () => void;
30-
}): ReactElement {
28+
}: OperatorChatSidebarProps): ReactElement {
3129
const [isSessionSidebarCollapsed, setIsSessionSidebarCollapsed] =
3230
useState(false);
3331
const router = useRouter();
@@ -114,3 +112,10 @@ export function OperatorChatSidebar({
114112
</>
115113
);
116114
}
115+
116+
export const OperatorChatSidebar = memo(
117+
OperatorChatSidebarView,
118+
areOperatorChatSidebarPropsEqual,
119+
);
120+
121+
OperatorChatSidebar.displayName = "OperatorChatSidebar";
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export interface OperatorChatSidebarProps {
2+
activeSessionId: string;
3+
isMobileOpen: boolean;
4+
onCloseMobileSidebar: () => void;
5+
onSearch: () => void;
6+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { ReactNode } from "react";
2+
3+
export interface WebOperatorLayoutProps {
4+
children: ReactNode;
5+
mobileSidebarTrigger: ReactNode;
6+
overlays: ReactNode;
7+
sidebar: ReactNode;
8+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { ReactElement } from "react";
2+
3+
import type { WebOperatorLayoutProps } from "./types/web-operator-layout.types";
4+
5+
export function WebOperatorLayout({
6+
children,
7+
mobileSidebarTrigger,
8+
overlays,
9+
sidebar,
10+
}: WebOperatorLayoutProps): ReactElement {
11+
return (
12+
<main className="relative grid h-[100dvh] max-h-[100dvh] min-w-0 grid-rows-[minmax(0,1fr)] overflow-x-clip bg-background md:grid-cols-[auto_minmax(0,1fr)]">
13+
{sidebar}
14+
{mobileSidebarTrigger}
15+
<div className="min-h-0 min-w-0">{children}</div>
16+
{overlays}
17+
</main>
18+
);
19+
}

packages/web/src/components/web-shell/web-operator-shell.tsx

Lines changed: 45 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type {
2828
CommandDraftRequest,
2929
OperatorIssueActionsContextValue,
3030
} from "./types/operator-issue-actions.types";
31+
import { WebOperatorLayout } from "./web-operator-layout";
3132
import { hrefForNavKey, navItems } from "./web-shell.constants";
3233

3334
function getActiveNavKey(pathname: string): SidebarNavKey {
@@ -126,44 +127,51 @@ export function WebOperatorShell({
126127
);
127128

128129
return (
129-
<main className="relative grid h-[100dvh] max-h-[100dvh] min-w-0 grid-rows-[minmax(0,1fr)] overflow-x-clip bg-background md:grid-cols-[auto_minmax(0,1fr)]">
130-
<OperatorChatSidebar
131-
activeSessionId={activeSessionId}
132-
isMobileOpen={isChatSidebarMobileOpen}
133-
onCloseMobileSidebar={closeChatSidebar}
134-
onSearch={openSearch}
135-
/>
136-
{!isChatSurface ? (
137-
<Button
138-
aria-label="Open chat sidebar"
139-
className="absolute left-4 top-4 z-20 cursor-pointer md:hidden"
140-
onClick={openChatSidebar}
141-
size="icon"
142-
type="button"
143-
variant="ghost"
144-
>
145-
<PanelLeft size={17} />
146-
</Button>
147-
) : null}
130+
<WebOperatorLayout
131+
mobileSidebarTrigger={
132+
!isChatSurface ? (
133+
<Button
134+
aria-label="Open chat sidebar"
135+
className="absolute left-4 top-4 z-20 cursor-pointer md:hidden"
136+
onClick={openChatSidebar}
137+
size="icon"
138+
type="button"
139+
variant="ghost"
140+
>
141+
<PanelLeft size={17} />
142+
</Button>
143+
) : null
144+
}
145+
overlays={
146+
<CommandSearchDialog
147+
activeKey={activeNavKey}
148+
boardError={searchTasksQuery.error}
149+
commandHistory={commandHistoryQuery.data}
150+
commandHistoryError={commandHistoryQuery.error}
151+
isBoardLoading={searchTasksQuery.isLoading}
152+
isCommandHistoryLoading={commandHistoryQuery.isLoading}
153+
isOpen={isSearchOpen}
154+
navItems={navItems}
155+
onClose={() => setIsSearchOpen(false)}
156+
onNavigate={navigateToSection}
157+
onNewIssue={createIssue}
158+
onOpenIssue={openIssue}
159+
onSelectCommand={selectChatCommandDraft}
160+
tasks={searchTasksQuery.data}
161+
/>
162+
}
163+
sidebar={
164+
<OperatorChatSidebar
165+
activeSessionId={activeSessionId}
166+
isMobileOpen={isChatSidebarMobileOpen}
167+
onCloseMobileSidebar={closeChatSidebar}
168+
onSearch={openSearch}
169+
/>
170+
}
171+
>
148172
<OperatorIssueActionsProvider value={issueActionsValue}>
149-
<div className="min-h-0 min-w-0">{children}</div>
173+
{children}
150174
</OperatorIssueActionsProvider>
151-
<CommandSearchDialog
152-
activeKey={activeNavKey}
153-
boardError={searchTasksQuery.error}
154-
commandHistory={commandHistoryQuery.data}
155-
commandHistoryError={commandHistoryQuery.error}
156-
isBoardLoading={searchTasksQuery.isLoading}
157-
isCommandHistoryLoading={commandHistoryQuery.isLoading}
158-
isOpen={isSearchOpen}
159-
navItems={navItems}
160-
onClose={() => setIsSearchOpen(false)}
161-
onNavigate={navigateToSection}
162-
onNewIssue={createIssue}
163-
onOpenIssue={openIssue}
164-
onSelectCommand={selectChatCommandDraft}
165-
tasks={searchTasksQuery.data}
166-
/>
167-
</main>
175+
</WebOperatorLayout>
168176
);
169177
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import { areOperatorChatSidebarPropsEqual } from "../src/components/web-shell/operator-chat-sidebar-render-utils";
4+
import type { OperatorChatSidebarProps } from "../src/components/web-shell/types/operator-chat-sidebar.types";
5+
6+
describe("operator chat sidebar render utilities", () => {
7+
it("treats ordinary page navigation props as equal", () => {
8+
const onCloseMobileSidebar = (): void => {};
9+
const onSearch = (): void => {};
10+
const previous = buildProps({ onCloseMobileSidebar, onSearch });
11+
const next = buildProps({ onCloseMobileSidebar, onSearch });
12+
13+
expect(areOperatorChatSidebarPropsEqual(previous, next)).toBe(true);
14+
});
15+
16+
it("rerenders when the active chat session changes", () => {
17+
const previous = buildProps({ activeSessionId: "session-1" });
18+
const next = buildProps({ activeSessionId: "session-2" });
19+
20+
expect(areOperatorChatSidebarPropsEqual(previous, next)).toBe(false);
21+
});
22+
});
23+
24+
function buildProps(
25+
overrides: Partial<OperatorChatSidebarProps> = {},
26+
): OperatorChatSidebarProps {
27+
return {
28+
activeSessionId: "",
29+
isMobileOpen: false,
30+
onCloseMobileSidebar: (): void => {},
31+
onSearch: (): void => {},
32+
...overrides,
33+
};
34+
}

0 commit comments

Comments
 (0)