diff --git a/e2e/workspace-responsive.e2e.ts b/e2e/workspace-responsive.e2e.ts new file mode 100644 index 0000000..7abd49b --- /dev/null +++ b/e2e/workspace-responsive.e2e.ts @@ -0,0 +1,66 @@ +import { expect, test } from "@playwright/test" + +test("fits desktop notebook panels inside a 13-inch viewport", async ({ + context, + page, +}) => { + await context.addCookies([ + { + name: "better-auth.session_token", + value: "playwright", + url: "http://localhost:3000", + }, + ]) + await page.setViewportSize({ width: 1280, height: 832 }) + await page.goto("/e2e/citation-dedupe") + + const layout = page.getByTestId("desktop-panel-layout") + const chatPanel = page.getByTestId("desktop-chat-panel") + await expect(layout).toBeVisible() + + await expect + .poll(async () => { + return layout.evaluate((element) => { + return element.scrollWidth <= element.clientWidth + }) + }) + .toBe(true) + + const measurements = await layout.evaluate((element) => { + return { + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + } + }) + const chatBounds = await chatPanel.boundingBox() + + expect(measurements.scrollWidth).toBeLessThanOrEqual( + measurements.clientWidth, + ) + expect(chatBounds?.x).toBeGreaterThanOrEqual(0) + expect((chatBounds?.x ?? 0) + (chatBounds?.width ?? 0)).toBeLessThanOrEqual( + measurements.clientWidth, + ) +}) + +test("uses the tabbed notebook layout below the desktop panel minimum", async ({ + context, + page, +}) => { + await context.addCookies([ + { + name: "better-auth.session_token", + value: "playwright", + url: "http://localhost:3000", + }, + ]) + await page.setViewportSize({ width: 1099, height: 832 }) + await page.goto("/e2e/citation-dedupe") + + await expect(page.getByTestId("desktop-panel-layout")).toBeHidden() + await expect( + page.getByRole("tab", { + name: /Assistant/u, + }), + ).toBeVisible() +}) diff --git a/src/components/chat-panel.tsx b/src/components/chat-panel.tsx index 51eef5e..47c8c5e 100644 --- a/src/components/chat-panel.tsx +++ b/src/components/chat-panel.tsx @@ -94,7 +94,7 @@ export function ChatPanel({ return (
diff --git a/src/components/workspace-desktop-panels.test.ts b/src/components/workspace-desktop-panels.test.ts index 2026442..80e58fb 100644 --- a/src/components/workspace-desktop-panels.test.ts +++ b/src/components/workspace-desktop-panels.test.ts @@ -5,6 +5,22 @@ import { describe, expect, it } from "vitest"; import { useWorkspaceDesktopPanels } from "./workspace-desktop-panels"; describe("useWorkspaceDesktopPanels", () => { + it("fits default desktop panel widths to the rendered layout width", () => { + const { result } = renderHook(() => useWorkspaceDesktopPanels()); + + act(() => { + result.current.handleDesktopLayoutElementChange(createPanelElement(1280)); + }); + + const totalWidth = + result.current.desktopPanelWidths.sources + + result.current.desktopPanelWidths.chunks + + result.current.desktopPanelWidths.chat; + + expect(totalWidth).toBe(1264); + expect(result.current.desktopPanelWidths.chat).toBeGreaterThanOrEqual(360); + }); + it("resizes desktop panels from their rendered widths during a drag", () => { const { result } = renderHook(() => useWorkspaceDesktopPanels()); diff --git a/src/components/workspace-desktop-panels.ts b/src/components/workspace-desktop-panels.ts index b44c945..ccc6251 100644 --- a/src/components/workspace-desktop-panels.ts +++ b/src/components/workspace-desktop-panels.ts @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { workspaceShellState } from "@/components/workspace-shell-state"; @@ -17,6 +17,9 @@ type DesktopPanelResizeDrag = { type WorkspaceDesktopPanels = { readonly desktopPanelWidths: DesktopPanelWidths; readonly minimumDesktopPanelWidth: number; + readonly handleDesktopLayoutElementChange: ( + element: HTMLDivElement | null, + ) => void; readonly handleDesktopPanelElementChange: ( panel: DesktopPanelKey, element: HTMLDivElement | null, @@ -37,7 +40,8 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels { const [desktopPanelWidths, setDesktopPanelWidths] = useState({ ...workspaceShellState.defaultDesktopPanelWidths, - }); + }); + const desktopLayoutResizeObserver = useRef(null); const desktopPanelElements = useRef< Record >({ @@ -47,6 +51,42 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels { }); const desktopPanelResizeDrag = useRef(null); + const fitDesktopPanelWidthsToElement = useCallback( + (element: HTMLDivElement): void => { + const renderedWidth = element.getBoundingClientRect().width; + setDesktopPanelWidths( + workspaceShellState.fitDesktopPanelWidthsToContainer(renderedWidth), + ); + }, + [], + ); + + useEffect(() => { + return () => { + desktopLayoutResizeObserver.current?.disconnect(); + }; + }, []); + + const handleDesktopLayoutElementChange = useCallback( + (element: HTMLDivElement | null): void => { + desktopLayoutResizeObserver.current?.disconnect(); + desktopLayoutResizeObserver.current = null; + + if (!element) return; + + fitDesktopPanelWidthsToElement(element); + + if (typeof ResizeObserver === "undefined") return; + + const resizeObserver = new ResizeObserver(() => { + fitDesktopPanelWidthsToElement(element); + }); + resizeObserver.observe(element); + desktopLayoutResizeObserver.current = resizeObserver; + }, + [fitDesktopPanelWidthsToElement], + ); + function getRenderedDesktopPanelWidth( panel: DesktopPanelKey, fallbackWidth: number, @@ -117,6 +157,7 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels { return { desktopPanelWidths, minimumDesktopPanelWidth: workspaceShellState.getMinimumDesktopPanelWidth(), + handleDesktopLayoutElementChange, handleDesktopPanelElementChange, handleDesktopPanelResize, handleDesktopPanelResizeEnd, diff --git a/src/components/workspace-shell-layout.test.ts b/src/components/workspace-shell-layout.test.ts index 14cd389..76999ed 100644 --- a/src/components/workspace-shell-layout.test.ts +++ b/src/components/workspace-shell-layout.test.ts @@ -48,6 +48,7 @@ describe("WorkspaceShellLayout", () => { onChatSend: vi.fn(), onCitationClick: vi.fn(), onCreateChatThread: vi.fn(), + onDesktopLayoutElementChange: vi.fn(), onDesktopPanelElementChange: vi.fn(), onDesktopPanelResize: vi.fn(), onDesktopPanelResizeEnd: vi.fn(), diff --git a/src/components/workspace-shell-layout.tsx b/src/components/workspace-shell-layout.tsx index f3d3dc4..74a5ec3 100644 --- a/src/components/workspace-shell-layout.tsx +++ b/src/components/workspace-shell-layout.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from "react" +import { useCallback, type ReactElement } from "react" import { ChatPanel } from "@/components/chat-panel" import { ChunksPanel } from "@/components/chunks-panel" @@ -77,6 +77,7 @@ export type WorkspaceShellLayoutProps = { citationId: string, ) => void | Promise readonly onCreateChatThread: () => void | Promise + readonly onDesktopLayoutElementChange: (element: HTMLDivElement | null) => void readonly onDesktopPanelElementChange: ( panel: DesktopPanelKey, element: HTMLDivElement | null, @@ -103,6 +104,14 @@ export type WorkspaceShellLayoutProps = { export function WorkspaceShellLayout( props: WorkspaceShellLayoutProps, ): ReactElement { + const { onDesktopLayoutElementChange } = props + const handleDesktopLayoutRef = useCallback( + (element: HTMLDivElement | null): void => { + onDesktopLayoutElementChange(element) + }, + [onDesktopLayoutElementChange], + ) + return (
@@ -264,7 +274,7 @@ export function WorkspaceShellLayout( id="panel-content" role="tabpanel" aria-labelledby="tab-content" - className={`lg:hidden flex-1 overflow-hidden pb-14 ${ + className={`min-[1116px]:hidden flex-1 overflow-hidden pb-14 ${ props.mobilePanel === "content" ? "flex flex-col" : "hidden" }`} > @@ -286,7 +296,7 @@ export function WorkspaceShellLayout( id="panel-chat" role="tabpanel" aria-labelledby="tab-chat" - className={`lg:hidden flex-1 overflow-hidden pb-14 ${ + className={`min-[1116px]:hidden flex-1 overflow-hidden pb-14 ${ props.mobilePanel === "chat" ? "flex flex-col" : "hidden" }`} > @@ -325,7 +335,7 @@ export function WorkspaceShellLayout( /> {props.chat.error && ( -
+
{props.chat.error}
)} diff --git a/src/components/workspace-shell-state.test.ts b/src/components/workspace-shell-state.test.ts index 72db37c..cb5c0a4 100644 --- a/src/components/workspace-shell-state.test.ts +++ b/src/components/workspace-shell-state.test.ts @@ -3,6 +3,26 @@ import { describe, expect, it } from "vitest"; import { workspaceShellState } from "./workspace-shell-state"; describe("workspaceShellState", () => { + it("fits default desktop panel widths inside a 13-inch viewport", () => { + const widths = workspaceShellState.fitDesktopPanelWidthsToContainer(1280); + const totalWidth = + widths.sources + + widths.chunks + + widths.chat + + workspaceShellState.desktopPanelGutterWidth * 2; + + expect(totalWidth).toBeLessThanOrEqual(1280); + expect(widths.sources).toBeGreaterThanOrEqual( + workspaceShellState.minimumDesktopPanelWidths.sources, + ); + expect(widths.chunks).toBeGreaterThanOrEqual( + workspaceShellState.minimumDesktopPanelWidths.chunks, + ); + expect(widths.chat).toBeGreaterThanOrEqual( + workspaceShellState.minimumDesktopPanelWidths.chat, + ); + }); + it("resizes neighboring desktop panels while preserving their combined width", () => { const resized = workspaceShellState.resizeDesktopPanelWidths( { diff --git a/src/components/workspace-shell-state.ts b/src/components/workspace-shell-state.ts index 90fb3a8..ed6bf93 100644 --- a/src/components/workspace-shell-state.ts +++ b/src/components/workspace-shell-state.ts @@ -16,6 +16,8 @@ type DesktopPanelKey = keyof typeof minimumDesktopPanelWidths type DesktopPanelWidths = Record +const desktopPanelKeys = ["sources", "chunks", "chat"] as const + type DesktopPanelResizeInput = { readonly leftPanel: DesktopPanelKey readonly rightPanel: DesktopPanelKey @@ -29,6 +31,9 @@ type WorkspaceShellStateModule = { readonly minimumDesktopPanelWidths: typeof minimumDesktopPanelWidths readonly defaultDesktopPanelWidths: typeof defaultDesktopPanelWidths readonly getMinimumDesktopPanelWidth: () => number + readonly fitDesktopPanelWidthsToContainer: ( + containerWidth: number, + ) => DesktopPanelWidths readonly resizeDesktopPanelWidths: ( currentWidths: Readonly, resize: DesktopPanelResizeInput, @@ -44,6 +49,65 @@ function getMinimumDesktopPanelWidth(): number { ) } +function getDefaultDesktopPanelContentWidth(): number { + return ( + defaultDesktopPanelWidths.sources + + defaultDesktopPanelWidths.chunks + + defaultDesktopPanelWidths.chat + ) +} + +function getMinimumDesktopPanelContentWidth(): number { + return ( + minimumDesktopPanelWidths.sources + + minimumDesktopPanelWidths.chunks + + minimumDesktopPanelWidths.chat + ) +} + +function fitDesktopPanelWidthsToContainer( + containerWidth: number, +): DesktopPanelWidths { + if (!Number.isFinite(containerWidth) || containerWidth <= 0) { + return { ...defaultDesktopPanelWidths } + } + + const availableContentWidth = containerWidth - desktopPanelGutterWidth * 2 + const defaultContentWidth = getDefaultDesktopPanelContentWidth() + if (availableContentWidth >= defaultContentWidth) { + return { ...defaultDesktopPanelWidths } + } + + const minimumContentWidth = getMinimumDesktopPanelContentWidth() + if (availableContentWidth <= minimumContentWidth) { + return { ...minimumDesktopPanelWidths } + } + + const defaultExtraWidth = defaultContentWidth - minimumContentWidth + const availableExtraWidth = availableContentWidth - minimumContentWidth + const fittedWidths = {} as DesktopPanelWidths + let assignedWidth = 0 + + for (const [index, panel] of desktopPanelKeys.entries()) { + const isLastPanel = index === desktopPanelKeys.length - 1 + if (isLastPanel) { + fittedWidths[panel] = availableContentWidth - assignedWidth + break + } + + const panelExtraWidth = + defaultDesktopPanelWidths[panel] - minimumDesktopPanelWidths[panel] + const fittedWidth = Math.round( + minimumDesktopPanelWidths[panel] + + (panelExtraWidth / defaultExtraWidth) * availableExtraWidth, + ) + fittedWidths[panel] = fittedWidth + assignedWidth += fittedWidth + } + + return fittedWidths +} + function resizeDesktopPanelWidths( currentWidths: Readonly, resize: DesktopPanelResizeInput, @@ -73,5 +137,6 @@ export const workspaceShellState: WorkspaceShellStateModule = { minimumDesktopPanelWidths, defaultDesktopPanelWidths, getMinimumDesktopPanelWidth, + fitDesktopPanelWidthsToContainer, resizeDesktopPanelWidths, } diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index 4e2af57..e931cf6 100644 --- a/src/components/workspace-shell.tsx +++ b/src/components/workspace-shell.tsx @@ -100,6 +100,7 @@ function WorkspaceShellContent({ const { desktopPanelWidths, minimumDesktopPanelWidth, + handleDesktopLayoutElementChange, handleDesktopPanelElementChange, handleDesktopPanelResize, handleDesktopPanelResizeEnd, @@ -150,6 +151,7 @@ function WorkspaceShellContent({ onChatSend={chatWorkflow.handleChatSend} onCitationClick={citationFocus.handleCitationClick} onCreateChatThread={chatWorkflow.handleCreateChatThread} + onDesktopLayoutElementChange={handleDesktopLayoutElementChange} onDesktopPanelElementChange={handleDesktopPanelElementChange} onDesktopPanelResize={handleDesktopPanelResize} onDesktopPanelResizeEnd={handleDesktopPanelResizeEnd}