diff --git a/apps/web/src/components/AppSidebarLayout.test.ts b/apps/web/src/components/AppSidebarLayout.test.ts new file mode 100644 index 00000000000..011fe979674 --- /dev/null +++ b/apps/web/src/components/AppSidebarLayout.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { shouldAcceptThreadSidebarWidth } from "./AppSidebarLayout"; + +describe("shouldAcceptThreadSidebarWidth", () => { + it("allows shrinking even when the sidebar is wider than the available content area", () => { + expect( + shouldAcceptThreadSidebarWidth({ + currentWidth: 900, + nextWidth: 800, + wrapperClientWidth: 700, + }), + ).toBe(true); + }); + + it("rejects expansion that would leave less than the minimum main content width", () => { + expect( + shouldAcceptThreadSidebarWidth({ + currentWidth: 500, + nextWidth: 600, + wrapperClientWidth: 1_000, + }), + ).toBe(false); + }); + + it("allows expansion when the main content minimum remains available", () => { + expect( + shouldAcceptThreadSidebarWidth({ + currentWidth: 300, + nextWidth: 340, + wrapperClientWidth: 1_000, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index d98f30a1e5c..680dcd8a987 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -11,6 +11,21 @@ import { const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; + +export function shouldAcceptThreadSidebarWidth({ + currentWidth, + nextWidth, + wrapperClientWidth, +}: { + currentWidth: number; + nextWidth: number; + wrapperClientWidth: number; +}): boolean { + return ( + nextWidth <= currentWidth || wrapperClientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH + ); +} + export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); @@ -61,8 +76,12 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { className="border-r border-border bg-card text-foreground" resizable={{ minWidth: THREAD_SIDEBAR_MIN_WIDTH, - shouldAcceptWidth: ({ nextWidth, wrapper }) => - wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, + shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) => + shouldAcceptThreadSidebarWidth({ + currentWidth, + nextWidth, + wrapperClientWidth: wrapper.clientWidth, + }), storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, }} > diff --git a/apps/web/src/components/NoActiveThreadState.test.ts b/apps/web/src/components/NoActiveThreadState.test.ts new file mode 100644 index 00000000000..4b87d93eac9 --- /dev/null +++ b/apps/web/src/components/NoActiveThreadState.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { shouldShowNoActiveThreadSidebarTrigger } from "./NoActiveThreadState"; + +describe("shouldShowNoActiveThreadSidebarTrigger", () => { + it("shows the trigger when the mobile sidebar sheet is closed", () => { + expect( + shouldShowNoActiveThreadSidebarTrigger({ + isMobile: true, + open: true, + openMobile: false, + }), + ).toBe(true); + }); + + it("hides the trigger when the mobile sidebar sheet is already open", () => { + expect( + shouldShowNoActiveThreadSidebarTrigger({ + isMobile: true, + open: true, + openMobile: true, + }), + ).toBe(false); + }); + + it("shows the trigger when the desktop sidebar is collapsed", () => { + expect( + shouldShowNoActiveThreadSidebarTrigger({ + isMobile: false, + open: false, + openMobile: false, + }), + ).toBe(true); + }); + + it("hides the trigger when the desktop sidebar is expanded", () => { + expect( + shouldShowNoActiveThreadSidebarTrigger({ + isMobile: false, + open: true, + openMobile: false, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index cd1f76ed2c2..e0a78a4b43f 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -1,9 +1,28 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; -import { SidebarInset, SidebarTrigger } from "./ui/sidebar"; +import { SidebarInset, SidebarTrigger, useSidebar } from "./ui/sidebar"; import { isElectron } from "../env"; import { cn } from "~/lib/utils"; +export function shouldShowNoActiveThreadSidebarTrigger({ + isMobile, + open, + openMobile, +}: { + isMobile: boolean; + open: boolean; + openMobile: boolean; +}): boolean { + return isMobile ? !openMobile : !open; +} + export function NoActiveThreadState() { + const { isMobile, open, openMobile } = useSidebar(); + const showSidebarTrigger = shouldShowNoActiveThreadSidebarTrigger({ + isMobile, + open, + openMobile, + }); + return (
@@ -16,12 +35,13 @@ export function NoActiveThreadState() { )} > {isElectron ? ( - - No active thread - +
+ {showSidebarTrigger ? : null} + No active thread +
) : (
- + {showSidebarTrigger ? : null} No active thread