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
66 changes: 66 additions & 0 deletions e2e/workspace-responsive.e2e.ts
Original file line number Diff line number Diff line change
@@ -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()
})
2 changes: 1 addition & 1 deletion src/components/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export function ChatPanel({
return (
<section
data-testid="chat-panel"
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"
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"
>
<AlertDialog
open={confirmThreadId !== null}
Expand Down
2 changes: 1 addition & 1 deletion src/components/mobile-tab-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function MobileTabBar({
}: MobileTabBarProps) {
return (
<nav
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"
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"
aria-label="Panel navigation"
role="tablist"
>
Expand Down
16 changes: 16 additions & 0 deletions src/components/workspace-desktop-panels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
45 changes: 43 additions & 2 deletions src/components/workspace-desktop-panels.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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,
Expand All @@ -37,7 +40,8 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels {
const [desktopPanelWidths, setDesktopPanelWidths] =
useState<DesktopPanelWidths>({
...workspaceShellState.defaultDesktopPanelWidths,
});
});
const desktopLayoutResizeObserver = useRef<ResizeObserver | null>(null);
const desktopPanelElements = useRef<
Record<DesktopPanelKey, HTMLDivElement | null>
>({
Expand All @@ -47,6 +51,42 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels {
});
const desktopPanelResizeDrag = useRef<DesktopPanelResizeDrag | null>(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,
Expand Down Expand Up @@ -117,6 +157,7 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels {
return {
desktopPanelWidths,
minimumDesktopPanelWidth: workspaceShellState.getMinimumDesktopPanelWidth(),
handleDesktopLayoutElementChange,
handleDesktopPanelElementChange,
handleDesktopPanelResize,
handleDesktopPanelResizeEnd,
Expand Down
1 change: 1 addition & 0 deletions src/components/workspace-shell-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
22 changes: 16 additions & 6 deletions src/components/workspace-shell-layout.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -77,6 +77,7 @@ export type WorkspaceShellLayoutProps = {
citationId: string,
) => void | Promise<void>
readonly onCreateChatThread: () => void | Promise<void>
readonly onDesktopLayoutElementChange: (element: HTMLDivElement | null) => void
readonly onDesktopPanelElementChange: (
panel: DesktopPanelKey,
element: HTMLDivElement | null,
Expand All @@ -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 (
<div className="flex h-screen w-full flex-col overflow-hidden bg-background">
<TopNav
Expand All @@ -115,7 +124,8 @@ export function WorkspaceShellLayout(

<div
data-testid="desktop-panel-layout"
className="relative hidden flex-1 overflow-x-auto overflow-y-hidden lg:block"
ref={handleDesktopLayoutRef}
className="relative hidden flex-1 overflow-x-auto overflow-y-hidden min-[1116px]:block"
>
<div
data-testid="desktop-resizable-panels"
Expand Down Expand Up @@ -242,7 +252,7 @@ export function WorkspaceShellLayout(
id="panel-sources"
role="tabpanel"
aria-labelledby="tab-sources"
className={`lg:hidden flex-1 overflow-hidden pb-14 ${
className={`min-[1116px]:hidden flex-1 overflow-hidden pb-14 ${
props.mobilePanel === "sources" ? "flex flex-col" : "hidden"
}`}
>
Expand All @@ -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"
}`}
>
Expand All @@ -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"
}`}
>
Expand Down Expand Up @@ -325,7 +335,7 @@ export function WorkspaceShellLayout(
/>

{props.chat.error && (
<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">
<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">
{props.chat.error}
</div>
)}
Expand Down
20 changes: 20 additions & 0 deletions src/components/workspace-shell-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
65 changes: 65 additions & 0 deletions src/components/workspace-shell-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ type DesktopPanelKey = keyof typeof minimumDesktopPanelWidths

type DesktopPanelWidths = Record<DesktopPanelKey, number>

const desktopPanelKeys = ["sources", "chunks", "chat"] as const

type DesktopPanelResizeInput = {
readonly leftPanel: DesktopPanelKey
readonly rightPanel: DesktopPanelKey
Expand All @@ -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<DesktopPanelWidths>,
resize: DesktopPanelResizeInput,
Expand All @@ -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<DesktopPanelWidths>,
resize: DesktopPanelResizeInput,
Expand Down Expand Up @@ -73,5 +137,6 @@ export const workspaceShellState: WorkspaceShellStateModule = {
minimumDesktopPanelWidths,
defaultDesktopPanelWidths,
getMinimumDesktopPanelWidth,
fitDesktopPanelWidthsToContainer,
resizeDesktopPanelWidths,
}
Loading
Loading