Skip to content

Commit d193688

Browse files
authored
Merge pull request #82 from Ontos-AI/fix/wangbinqi/notebook-responsive-panels
Fix notebook desktop panel overflow on 13-inch viewports
2 parents c7ea6f7 + 95e0c7e commit d193688

10 files changed

Lines changed: 231 additions & 10 deletions

e2e/workspace-responsive.e2e.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { expect, test } from "@playwright/test"
2+
3+
test("fits desktop notebook panels inside a 13-inch viewport", async ({
4+
context,
5+
page,
6+
}) => {
7+
await context.addCookies([
8+
{
9+
name: "better-auth.session_token",
10+
value: "playwright",
11+
url: "http://localhost:3000",
12+
},
13+
])
14+
await page.setViewportSize({ width: 1280, height: 832 })
15+
await page.goto("/e2e/citation-dedupe")
16+
17+
const layout = page.getByTestId("desktop-panel-layout")
18+
const chatPanel = page.getByTestId("desktop-chat-panel")
19+
await expect(layout).toBeVisible()
20+
21+
await expect
22+
.poll(async () => {
23+
return layout.evaluate((element) => {
24+
return element.scrollWidth <= element.clientWidth
25+
})
26+
})
27+
.toBe(true)
28+
29+
const measurements = await layout.evaluate((element) => {
30+
return {
31+
clientWidth: element.clientWidth,
32+
scrollWidth: element.scrollWidth,
33+
}
34+
})
35+
const chatBounds = await chatPanel.boundingBox()
36+
37+
expect(measurements.scrollWidth).toBeLessThanOrEqual(
38+
measurements.clientWidth,
39+
)
40+
expect(chatBounds?.x).toBeGreaterThanOrEqual(0)
41+
expect((chatBounds?.x ?? 0) + (chatBounds?.width ?? 0)).toBeLessThanOrEqual(
42+
measurements.clientWidth,
43+
)
44+
})
45+
46+
test("uses the tabbed notebook layout below the desktop panel minimum", async ({
47+
context,
48+
page,
49+
}) => {
50+
await context.addCookies([
51+
{
52+
name: "better-auth.session_token",
53+
value: "playwright",
54+
url: "http://localhost:3000",
55+
},
56+
])
57+
await page.setViewportSize({ width: 1099, height: 832 })
58+
await page.goto("/e2e/citation-dedupe")
59+
60+
await expect(page.getByTestId("desktop-panel-layout")).toBeHidden()
61+
await expect(
62+
page.getByRole("tab", {
63+
name: /Assistant/u,
64+
}),
65+
).toBeVisible()
66+
})

src/components/chat-panel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export function ChatPanel({
9494
return (
9595
<section
9696
data-testid="chat-panel"
97-
className="relative z-0 flex h-full w-full max-w-full min-w-0 flex-col overflow-hidden border-border/70 bg-muted/40 lg:border-l"
97+
className="relative z-0 flex h-full w-full max-w-full min-w-0 flex-col overflow-hidden border-border/70 bg-muted/40 min-[1116px]:border-l"
9898
>
9999
<AlertDialog
100100
open={confirmThreadId !== null}

src/components/mobile-tab-bar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function MobileTabBar({
2424
}: MobileTabBarProps) {
2525
return (
2626
<nav
27-
className="fixed inset-x-0 bottom-0 z-50 flex h-14 shrink-0 items-center justify-around border-t border-border/70 bg-background/95 backdrop-blur-sm lg:hidden"
27+
className="fixed inset-x-0 bottom-0 z-50 flex h-14 shrink-0 items-center justify-around border-t border-border/70 bg-background/95 backdrop-blur-sm min-[1116px]:hidden"
2828
aria-label="Panel navigation"
2929
role="tablist"
3030
>

src/components/workspace-desktop-panels.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ import { describe, expect, it } from "vitest";
55
import { useWorkspaceDesktopPanels } from "./workspace-desktop-panels";
66

77
describe("useWorkspaceDesktopPanels", () => {
8+
it("fits default desktop panel widths to the rendered layout width", () => {
9+
const { result } = renderHook(() => useWorkspaceDesktopPanels());
10+
11+
act(() => {
12+
result.current.handleDesktopLayoutElementChange(createPanelElement(1280));
13+
});
14+
15+
const totalWidth =
16+
result.current.desktopPanelWidths.sources +
17+
result.current.desktopPanelWidths.chunks +
18+
result.current.desktopPanelWidths.chat;
19+
20+
expect(totalWidth).toBe(1264);
21+
expect(result.current.desktopPanelWidths.chat).toBeGreaterThanOrEqual(360);
22+
});
23+
824
it("resizes desktop panels from their rendered widths during a drag", () => {
925
const { result } = renderHook(() => useWorkspaceDesktopPanels());
1026

src/components/workspace-desktop-panels.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useRef, useState } from "react";
3+
import { useCallback, useEffect, useRef, useState } from "react";
44

55
import { workspaceShellState } from "@/components/workspace-shell-state";
66

@@ -17,6 +17,9 @@ type DesktopPanelResizeDrag = {
1717
type WorkspaceDesktopPanels = {
1818
readonly desktopPanelWidths: DesktopPanelWidths;
1919
readonly minimumDesktopPanelWidth: number;
20+
readonly handleDesktopLayoutElementChange: (
21+
element: HTMLDivElement | null,
22+
) => void;
2023
readonly handleDesktopPanelElementChange: (
2124
panel: DesktopPanelKey,
2225
element: HTMLDivElement | null,
@@ -37,7 +40,8 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels {
3740
const [desktopPanelWidths, setDesktopPanelWidths] =
3841
useState<DesktopPanelWidths>({
3942
...workspaceShellState.defaultDesktopPanelWidths,
40-
});
43+
});
44+
const desktopLayoutResizeObserver = useRef<ResizeObserver | null>(null);
4145
const desktopPanelElements = useRef<
4246
Record<DesktopPanelKey, HTMLDivElement | null>
4347
>({
@@ -47,6 +51,42 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels {
4751
});
4852
const desktopPanelResizeDrag = useRef<DesktopPanelResizeDrag | null>(null);
4953

54+
const fitDesktopPanelWidthsToElement = useCallback(
55+
(element: HTMLDivElement): void => {
56+
const renderedWidth = element.getBoundingClientRect().width;
57+
setDesktopPanelWidths(
58+
workspaceShellState.fitDesktopPanelWidthsToContainer(renderedWidth),
59+
);
60+
},
61+
[],
62+
);
63+
64+
useEffect(() => {
65+
return () => {
66+
desktopLayoutResizeObserver.current?.disconnect();
67+
};
68+
}, []);
69+
70+
const handleDesktopLayoutElementChange = useCallback(
71+
(element: HTMLDivElement | null): void => {
72+
desktopLayoutResizeObserver.current?.disconnect();
73+
desktopLayoutResizeObserver.current = null;
74+
75+
if (!element) return;
76+
77+
fitDesktopPanelWidthsToElement(element);
78+
79+
if (typeof ResizeObserver === "undefined") return;
80+
81+
const resizeObserver = new ResizeObserver(() => {
82+
fitDesktopPanelWidthsToElement(element);
83+
});
84+
resizeObserver.observe(element);
85+
desktopLayoutResizeObserver.current = resizeObserver;
86+
},
87+
[fitDesktopPanelWidthsToElement],
88+
);
89+
5090
function getRenderedDesktopPanelWidth(
5191
panel: DesktopPanelKey,
5292
fallbackWidth: number,
@@ -117,6 +157,7 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels {
117157
return {
118158
desktopPanelWidths,
119159
minimumDesktopPanelWidth: workspaceShellState.getMinimumDesktopPanelWidth(),
160+
handleDesktopLayoutElementChange,
120161
handleDesktopPanelElementChange,
121162
handleDesktopPanelResize,
122163
handleDesktopPanelResizeEnd,

src/components/workspace-shell-layout.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ describe("WorkspaceShellLayout", () => {
4848
onChatSend: vi.fn(),
4949
onCitationClick: vi.fn(),
5050
onCreateChatThread: vi.fn(),
51+
onDesktopLayoutElementChange: vi.fn(),
5152
onDesktopPanelElementChange: vi.fn(),
5253
onDesktopPanelResize: vi.fn(),
5354
onDesktopPanelResizeEnd: vi.fn(),

src/components/workspace-shell-layout.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ReactElement } from "react"
1+
import { useCallback, type ReactElement } from "react"
22

33
import { ChatPanel } from "@/components/chat-panel"
44
import { ChunksPanel } from "@/components/chunks-panel"
@@ -77,6 +77,7 @@ export type WorkspaceShellLayoutProps = {
7777
citationId: string,
7878
) => void | Promise<void>
7979
readonly onCreateChatThread: () => void | Promise<void>
80+
readonly onDesktopLayoutElementChange: (element: HTMLDivElement | null) => void
8081
readonly onDesktopPanelElementChange: (
8182
panel: DesktopPanelKey,
8283
element: HTMLDivElement | null,
@@ -103,6 +104,14 @@ export type WorkspaceShellLayoutProps = {
103104
export function WorkspaceShellLayout(
104105
props: WorkspaceShellLayoutProps,
105106
): ReactElement {
107+
const { onDesktopLayoutElementChange } = props
108+
const handleDesktopLayoutRef = useCallback(
109+
(element: HTMLDivElement | null): void => {
110+
onDesktopLayoutElementChange(element)
111+
},
112+
[onDesktopLayoutElementChange],
113+
)
114+
106115
return (
107116
<div className="flex h-screen w-full flex-col overflow-hidden bg-background">
108117
<TopNav
@@ -115,7 +124,8 @@ export function WorkspaceShellLayout(
115124

116125
<div
117126
data-testid="desktop-panel-layout"
118-
className="relative hidden flex-1 overflow-x-auto overflow-y-hidden lg:block"
127+
ref={handleDesktopLayoutRef}
128+
className="relative hidden flex-1 overflow-x-auto overflow-y-hidden min-[1116px]:block"
119129
>
120130
<div
121131
data-testid="desktop-resizable-panels"
@@ -242,7 +252,7 @@ export function WorkspaceShellLayout(
242252
id="panel-sources"
243253
role="tabpanel"
244254
aria-labelledby="tab-sources"
245-
className={`lg:hidden flex-1 overflow-hidden pb-14 ${
255+
className={`min-[1116px]:hidden flex-1 overflow-hidden pb-14 ${
246256
props.mobilePanel === "sources" ? "flex flex-col" : "hidden"
247257
}`}
248258
>
@@ -264,7 +274,7 @@ export function WorkspaceShellLayout(
264274
id="panel-content"
265275
role="tabpanel"
266276
aria-labelledby="tab-content"
267-
className={`lg:hidden flex-1 overflow-hidden pb-14 ${
277+
className={`min-[1116px]:hidden flex-1 overflow-hidden pb-14 ${
268278
props.mobilePanel === "content" ? "flex flex-col" : "hidden"
269279
}`}
270280
>
@@ -286,7 +296,7 @@ export function WorkspaceShellLayout(
286296
id="panel-chat"
287297
role="tabpanel"
288298
aria-labelledby="tab-chat"
289-
className={`lg:hidden flex-1 overflow-hidden pb-14 ${
299+
className={`min-[1116px]:hidden flex-1 overflow-hidden pb-14 ${
290300
props.mobilePanel === "chat" ? "flex flex-col" : "hidden"
291301
}`}
292302
>
@@ -325,7 +335,7 @@ export function WorkspaceShellLayout(
325335
/>
326336

327337
{props.chat.error && (
328-
<div className="fixed bottom-18 right-4 z-50 max-w-sm rounded-lg border border-destructive/30 bg-background px-4 py-3 text-sm text-destructive shadow-lg lg:bottom-4">
338+
<div className="fixed bottom-18 right-4 z-50 max-w-sm rounded-lg border border-destructive/30 bg-background px-4 py-3 text-sm text-destructive shadow-lg min-[1116px]:bottom-4">
329339
{props.chat.error}
330340
</div>
331341
)}

src/components/workspace-shell-state.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,26 @@ import { describe, expect, it } from "vitest";
33
import { workspaceShellState } from "./workspace-shell-state";
44

55
describe("workspaceShellState", () => {
6+
it("fits default desktop panel widths inside a 13-inch viewport", () => {
7+
const widths = workspaceShellState.fitDesktopPanelWidthsToContainer(1280);
8+
const totalWidth =
9+
widths.sources +
10+
widths.chunks +
11+
widths.chat +
12+
workspaceShellState.desktopPanelGutterWidth * 2;
13+
14+
expect(totalWidth).toBeLessThanOrEqual(1280);
15+
expect(widths.sources).toBeGreaterThanOrEqual(
16+
workspaceShellState.minimumDesktopPanelWidths.sources,
17+
);
18+
expect(widths.chunks).toBeGreaterThanOrEqual(
19+
workspaceShellState.minimumDesktopPanelWidths.chunks,
20+
);
21+
expect(widths.chat).toBeGreaterThanOrEqual(
22+
workspaceShellState.minimumDesktopPanelWidths.chat,
23+
);
24+
});
25+
626
it("resizes neighboring desktop panels while preserving their combined width", () => {
727
const resized = workspaceShellState.resizeDesktopPanelWidths(
828
{

src/components/workspace-shell-state.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ type DesktopPanelKey = keyof typeof minimumDesktopPanelWidths
1616

1717
type DesktopPanelWidths = Record<DesktopPanelKey, number>
1818

19+
const desktopPanelKeys = ["sources", "chunks", "chat"] as const
20+
1921
type DesktopPanelResizeInput = {
2022
readonly leftPanel: DesktopPanelKey
2123
readonly rightPanel: DesktopPanelKey
@@ -29,6 +31,9 @@ type WorkspaceShellStateModule = {
2931
readonly minimumDesktopPanelWidths: typeof minimumDesktopPanelWidths
3032
readonly defaultDesktopPanelWidths: typeof defaultDesktopPanelWidths
3133
readonly getMinimumDesktopPanelWidth: () => number
34+
readonly fitDesktopPanelWidthsToContainer: (
35+
containerWidth: number,
36+
) => DesktopPanelWidths
3237
readonly resizeDesktopPanelWidths: (
3338
currentWidths: Readonly<DesktopPanelWidths>,
3439
resize: DesktopPanelResizeInput,
@@ -44,6 +49,65 @@ function getMinimumDesktopPanelWidth(): number {
4449
)
4550
}
4651

52+
function getDefaultDesktopPanelContentWidth(): number {
53+
return (
54+
defaultDesktopPanelWidths.sources +
55+
defaultDesktopPanelWidths.chunks +
56+
defaultDesktopPanelWidths.chat
57+
)
58+
}
59+
60+
function getMinimumDesktopPanelContentWidth(): number {
61+
return (
62+
minimumDesktopPanelWidths.sources +
63+
minimumDesktopPanelWidths.chunks +
64+
minimumDesktopPanelWidths.chat
65+
)
66+
}
67+
68+
function fitDesktopPanelWidthsToContainer(
69+
containerWidth: number,
70+
): DesktopPanelWidths {
71+
if (!Number.isFinite(containerWidth) || containerWidth <= 0) {
72+
return { ...defaultDesktopPanelWidths }
73+
}
74+
75+
const availableContentWidth = containerWidth - desktopPanelGutterWidth * 2
76+
const defaultContentWidth = getDefaultDesktopPanelContentWidth()
77+
if (availableContentWidth >= defaultContentWidth) {
78+
return { ...defaultDesktopPanelWidths }
79+
}
80+
81+
const minimumContentWidth = getMinimumDesktopPanelContentWidth()
82+
if (availableContentWidth <= minimumContentWidth) {
83+
return { ...minimumDesktopPanelWidths }
84+
}
85+
86+
const defaultExtraWidth = defaultContentWidth - minimumContentWidth
87+
const availableExtraWidth = availableContentWidth - minimumContentWidth
88+
const fittedWidths = {} as DesktopPanelWidths
89+
let assignedWidth = 0
90+
91+
for (const [index, panel] of desktopPanelKeys.entries()) {
92+
const isLastPanel = index === desktopPanelKeys.length - 1
93+
if (isLastPanel) {
94+
fittedWidths[panel] = availableContentWidth - assignedWidth
95+
break
96+
}
97+
98+
const panelExtraWidth =
99+
defaultDesktopPanelWidths[panel] - minimumDesktopPanelWidths[panel]
100+
const fittedWidth = Math.round(
101+
minimumDesktopPanelWidths[panel] +
102+
(panelExtraWidth / defaultExtraWidth) * availableExtraWidth,
103+
)
104+
fittedWidths[panel] = fittedWidth
105+
assignedWidth += fittedWidth
106+
}
107+
108+
return fittedWidths
109+
}
110+
47111
function resizeDesktopPanelWidths(
48112
currentWidths: Readonly<DesktopPanelWidths>,
49113
resize: DesktopPanelResizeInput,
@@ -73,5 +137,6 @@ export const workspaceShellState: WorkspaceShellStateModule = {
73137
minimumDesktopPanelWidths,
74138
defaultDesktopPanelWidths,
75139
getMinimumDesktopPanelWidth,
140+
fitDesktopPanelWidthsToContainer,
76141
resizeDesktopPanelWidths,
77142
}

0 commit comments

Comments
 (0)