diff --git a/README.md b/README.md index 006af101c5..59d3d1b916 100644 --- a/README.md +++ b/README.md @@ -1464,6 +1464,16 @@ Use it as-is. Or tear it apart and make it yours. ## Core Features +### Workspace + +The workspace keeps a collapsed icon sidebar visible on narrow screens. Its +toggle opens the full navigation drawer; closing the drawer returns to the icon +bar without changing your saved desktop sidebar preference. Navigating to another +page also closes the mobile drawer and preserves the destination page's focus. +Dismissing the drawer returns keyboard focus to the control that opened it. +The Agents gallery header wraps to the available width so its import and create +actions remain visible beside the compact sidebar on narrow screens. + ### Skills & Tools Open **Capability Center** from the workspace sidebar to manage **Plugins** diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 42060d835d..0b58946a46 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -8,12 +8,9 @@ DeerFlow's Next.js 16 frontend provides thread-based AI conversations with strea **Stack**: Next.js 16, React 19, TypeScript 5.8, Tailwind CSS 4, pnpm 10.26.2. Requires Node.js 22+ and pnpm 10.26.2+. -### Core dependencies - -- **LangGraph SDK** (`@langchain/langgraph-sdk` ^1.5.3) — Agent orchestration and streaming -- **LangChain Core** (`@langchain/core` ^1.1.15) — Fundamental AI building blocks -- **TanStack Query** (`@tanstack/react-query` ^5.90.17) — Server state management -- **UI**: Shadcn UI, MagicUI, React Bits, and Vercel AI SDK elements (generated from registries — see Code Style) +Core dependencies: `@langchain/langgraph-sdk` ^1.5.3 for orchestration/streaming, +`@langchain/core` ^1.1.15, and `@tanstack/react-query` ^5.90.17 for server state. +Registry UI: Shadcn, MagicUI, React Bits, Vercel AI SDK (see Code Style). `pnpm-workspace.yaml` overrides vulnerable `@xmldom/xmldom` 0.9.x releases to 0.9.12 for GHSA-965w-775f-mr7g. Nextra pulls it in through MathJax and @@ -23,18 +20,12 @@ upstream dependency chain resolves a patched version without it; regenerate ## Commands -| Command | Purpose | -| ---------------- | --------------------------------------------- | -| `pnpm dev` | Start the development server with Webpack | -| `pnpm build` | Production build | -| `pnpm check` | Lint + type check (run before committing) | -| `pnpm lint` | ESLint only | -| `pnpm lint:fix` | ESLint with auto-fix | -| `pnpm format` | Prettier check (`pnpm format:write` to apply) | -| `pnpm test` | Run unit tests with Rstest | -| `pnpm test:e2e` | Run E2E tests with Playwright (Chromium) | -| `pnpm typecheck` | TypeScript type check (`tsc --noEmit`) | -| `pnpm start` | Start production server | +Run from `frontend/`; host pnpm calls use `../scripts/pnpm.py`. + +- `pnpm dev`: Webpack development server; `pnpm build` / `pnpm start`: production build/server. +- `pnpm check`: ESLint + TypeScript (required before committing); `pnpm lint` / `pnpm typecheck`: individual checks; `pnpm lint:fix`: ESLint fixes. +- `pnpm format` / `pnpm format:write`: Prettier check/write. +- `pnpm test`: Rstest unit tests; `pnpm test:e2e`: Playwright Chromium E2E tests. Rstest unit tests under `tests/unit/` mirror `src/` (e.g., `tests/unit/core/api/stream-mode.test.ts` tests `src/core/api/stream-mode.ts`). Import source modules via `@/`. @@ -169,6 +160,15 @@ DOM regression: `new-agent-save.dom.test.tsx`. - **Path alias**: `@/*` maps to `src/*`. - **Components**: `ui/` and `ai-elements/` are generated from registries (Shadcn, MagicUI, React Bits, Vercel AI SDK) — don't manually edit these. +Sidebar customization: `components/workspace/workspace-sidebar-frame.tsx`. +Rail/drawer mount one tree; `useWorkspaceSidebarExpanded` preserves desktop +`SidebarProvider.open`. Keep polling/delete-dialog provider outside that tree. +Reuse stock `Sidebar`; close before link navigation and on pathname changes, +preserving page focus. Dismissal restores the opener or remounted rail trigger. +`WorkspaceSidebarTrigger` owns surface icons/ARIA; keep generated UI unchanged. +Test frame DOM behavior and `tests/e2e/sidebar.spec.ts`. +Agents headers/actions wrap with the rail; sidebar E2E tests cover bounds. + Scheduled-task list search filters the current authorized query result by title or prompt, composing with status/type filters and thread scope. Selection must derive from the filtered list so hidden tasks cannot remain actionable. Keep literal @@ -234,7 +234,8 @@ round-trip validation must still reject newly entered skipped times. ## Contributing -Features follow `src/` ownership, include TypeScript types/error handling, unit tests (`tests/unit/`, `pnpm test`) and E2E (`tests/e2e/`, `pnpm test:e2e`). Run `pnpm check` before committing; update AGENTS.md when architecture, commands or conventions change. +Follow `src/` ownership, add types/error handling and unit/E2E coverage, run +`pnpm check`, and update this guide for architecture, command, or convention changes. Route asset budgets are enforced with `pnpm perf:check`. The command measures `/login` from a normal production build, then builds in static-demo mode for the diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 21f2b54ba4..49134d7aa4 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -7,7 +7,6 @@ import { toast } from "sonner"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { Button } from "@/components/ui/button"; -import { SidebarTrigger } from "@/components/ui/sidebar"; import { AgentWelcome } from "@/components/workspace/agent-welcome"; import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { BrowserTrigger } from "@/components/workspace/browser-view"; @@ -43,6 +42,7 @@ import { TodoList } from "@/components/workspace/todo-list"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { Tooltip } from "@/components/workspace/tooltip"; import { useActiveGoal } from "@/components/workspace/use-active-goal"; +import { WorkspaceSidebarTrigger } from "@/components/workspace/workspace-sidebar-trigger"; import { useAgent } from "@/core/agents"; import { useAuth } from "@/core/auth/AuthProvider"; import { hasPermission, PERMISSIONS } from "@/core/auth/permissions"; @@ -491,7 +491,7 @@ export default function AgentChatPage() { : "bg-background/80 z-30 shadow-xs backdrop-blur", )} > - + {/* Agent badge */}
diff --git a/frontend/src/components/workspace/agents/agent-gallery.tsx b/frontend/src/components/workspace/agents/agent-gallery.tsx index c2f84cc728..f057cc459b 100644 --- a/frontend/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend/src/components/workspace/agents/agent-gallery.tsx @@ -78,14 +78,14 @@ export function AgentGallery() { return (
{/* Page header */} -
-
+
+

{t.agents.title}

{t.agents.description}

-
+
@@ -51,7 +51,7 @@ export function CapabilityCenter() { return (
- + {t.breadcrumb.workspace} / {t.capabilities.title} diff --git a/frontend/src/components/workspace/channels/workspace-channels-list.tsx b/frontend/src/components/workspace/channels/workspace-channels-list.tsx index f470b5449b..e75b8e38fb 100644 --- a/frontend/src/components/workspace/channels/workspace-channels-list.tsx +++ b/frontend/src/components/workspace/channels/workspace-channels-list.tsx @@ -10,7 +10,6 @@ import { SidebarGroupLabel, SidebarMenu, SidebarMenuItem, - useSidebar, } from "@/components/ui/sidebar"; import { Skeleton } from "@/components/ui/skeleton"; import { @@ -33,6 +32,8 @@ import type { ChannelProvider } from "@/core/channels/types"; import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; +import { useWorkspaceSidebarExpanded } from "../workspace-sidebar-frame"; + import { ChannelProviderIcon } from "./channel-provider-icon"; import { ChannelRuntimeConfigDialog } from "./channel-runtime-config-dialog"; import { ChannelScheduledUpdates } from "./channel-scheduled-updates"; @@ -54,7 +55,7 @@ function getProviderUnavailableReason( } export function WorkspaceChannelsList() { - const { open: isSidebarOpen } = useSidebar(); + const isSidebarOpen = useWorkspaceSidebarExpanded(); const { t } = useI18n(); const { enabled, providers, isLoading, error } = useChannelProviders(); const connectMutation = useConnectChannelProvider(); diff --git a/frontend/src/components/workspace/chats/chat-page.tsx b/frontend/src/components/workspace/chats/chat-page.tsx index c839c2a5ed..bfed5e25db 100644 --- a/frontend/src/components/workspace/chats/chat-page.tsx +++ b/frontend/src/components/workspace/chats/chat-page.tsx @@ -8,7 +8,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; -import { SidebarTrigger } from "@/components/ui/sidebar"; import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { BrowserTrigger } from "@/components/workspace/browser-view"; import { ContextUsageBadge } from "@/components/workspace/context-usage-badge"; @@ -39,6 +38,7 @@ import { TodoList } from "@/components/workspace/todo-list"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { useActiveGoal } from "@/components/workspace/use-active-goal"; import { Welcome } from "@/components/workspace/welcome"; +import { WorkspaceSidebarTrigger } from "@/components/workspace/workspace-sidebar-trigger"; import { useAuth } from "@/core/auth/AuthProvider"; import { hasPermission, PERMISSIONS } from "@/core/auth/permissions"; import { @@ -599,7 +599,7 @@ export default function ChatPage() { : "bg-background/80 z-30 shadow-xs backdrop-blur", )} > - {!isMock && } + {!isMock && }
- + {t.breadcrumb.workspace} / {title} diff --git a/frontend/src/components/workspace/workspace-container.tsx b/frontend/src/components/workspace/workspace-container.tsx index 5f9a9173ee..143781df00 100644 --- a/frontend/src/components/workspace/workspace-container.tsx +++ b/frontend/src/components/workspace/workspace-container.tsx @@ -12,12 +12,12 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import { SidebarTrigger } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; import { GithubIcon } from "./github-icon"; import { Tooltip } from "./tooltip"; +import { WorkspaceSidebarTrigger } from "./workspace-sidebar-trigger"; // Workspace sections that have an index route (/workspace/
/page.tsx) // and can therefore be linked to from the breadcrumb. const LINKABLE_SECTIONS: Record = { @@ -60,7 +60,7 @@ export function WorkspaceHeader({ {...props} >
- + {segments?.[0] && ( diff --git a/frontend/src/components/workspace/workspace-header.tsx b/frontend/src/components/workspace/workspace-header.tsx index db68c34d96..a352d5eb02 100644 --- a/frontend/src/components/workspace/workspace-header.tsx +++ b/frontend/src/components/workspace/workspace-header.tsx @@ -8,16 +8,19 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, - SidebarTrigger, useSidebar, } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; import { env } from "@/env"; import { cn } from "@/lib/utils"; +import { useWorkspaceSidebarExpanded } from "./workspace-sidebar-frame"; +import { WorkspaceSidebarTrigger } from "./workspace-sidebar-trigger"; + export function WorkspaceHeader({ className }: { className?: string }) { const { t } = useI18n(); - const { state } = useSidebar(); + const { isMobile } = useSidebar(); + const isSidebarOpen = useWorkspaceSidebarExpanded(); const pathname = usePathname(); return ( <> @@ -27,12 +30,22 @@ export function WorkspaceHeader({ className }: { className?: string }) { className, )} > - {state === "collapsed" ? ( + {!isSidebarOpen ? (
-
+
DF
- +
) : (
@@ -45,7 +58,7 @@ export function WorkspaceHeader({ className }: { className?: string }) { DeerFlow
)} - +
)}
diff --git a/frontend/src/components/workspace/workspace-nav-menu.tsx b/frontend/src/components/workspace/workspace-nav-menu.tsx index 98cd2355c5..a7bd68124a 100644 --- a/frontend/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend/src/components/workspace/workspace-nav-menu.tsx @@ -23,12 +23,12 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, - useSidebar, } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; import { GithubIcon } from "./github-icon"; import { useSettingsDialog } from "./settings"; +import { useWorkspaceSidebarExpanded } from "./workspace-sidebar-frame"; function NavMenuButtonContent({ isSidebarOpen, @@ -53,7 +53,7 @@ function NavMenuButtonContent({ export function WorkspaceNavMenu() { const { openSettings } = useSettingsDialog(); const [mounted, setMounted] = useState(false); - const { open: isSidebarOpen } = useSidebar(); + const isSidebarOpen = useWorkspaceSidebarExpanded(); const { t } = useI18n(); useEffect(() => { diff --git a/frontend/src/components/workspace/workspace-sidebar-frame.tsx b/frontend/src/components/workspace/workspace-sidebar-frame.tsx new file mode 100644 index 0000000000..901599a45d --- /dev/null +++ b/frontend/src/components/workspace/workspace-sidebar-frame.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { + createContext, + useContext, + useEffect, + useLayoutEffect, + useRef, + type ReactNode, + type RefObject, +} from "react"; + +import { Sidebar, useSidebar } from "@/components/ui/sidebar"; +import { cn } from "@/lib/utils"; + +const ExpandedContext = createContext(undefined); + +// Workspace content follows the surface it is rendered in, independently of +// the saved desktop preference owned by SidebarProvider. +export function useWorkspaceSidebarExpanded() { + const { open } = useSidebar(); + return useContext(ExpandedContext) ?? open; +} + +function Presentation({ + expanded, + children, +}: { + expanded: boolean | undefined; + children: ReactNode; +}) { + const { isMobile, setOpenMobile } = useSidebar(); + return ( + +
{ + if ( + !isMobile || + event.defaultPrevented || + !(event.target instanceof Element) || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return; + const link = event.target.closest("a[href]"); + if ( + !link || + link.hasAttribute("download") || + (link.target && link.target !== "_self") + ) + return; + const destination = new URL(link.href, window.location.href); + if ( + destination.origin === window.location.origin && + destination.pathname !== window.location.pathname + ) { + // Release the focus trap before the destination mounts its input. + setOpenMobile(false); + } + }} + > + {children} +
+
+ ); +} + +function RestoreSidebarFocus({ + railRef, + pathnameRef, +}: { + railRef: RefObject; + pathnameRef: RefObject; +}) { + useLayoutEffect(() => { + // Capture the opener before the drawer's focus scope moves focus inside. + const opener = document.activeElement; + const openedOn = pathnameRef.current; + const restoreFocus = () => { + if ( + pathnameRef.current !== openedOn || + document.activeElement !== document.body + ) { + return; + } + const target = + opener instanceof HTMLElement && + opener !== document.body && + opener.isConnected + ? opener + : railRef.current?.querySelector( + '[data-sidebar="trigger"]', + ); + target?.focus(); + }; + // Read the latest refs after the stock drawer portal has unmounted. + return () => { + setTimeout(restoreFocus, 0); + }; + }, [railRef, pathnameRef]); + return null; +} + +export function WorkspaceSidebarFrame({ + children, + collapsible = "icon", + variant = "sidebar", + side = "left", + className, + style, + ...props +}: React.ComponentProps) { + const { isMobile, open, openMobile, setOpenMobile } = useSidebar(); + const pathname = usePathname(); + const pathnameRef = useRef(pathname); + const railRef = useRef(null); + + useEffect(() => { + pathnameRef.current = pathname; + setOpenMobile(false); + }, [pathname, setOpenMobile]); + + if (!isMobile || collapsible !== "icon" || open) { + return ( + + {isMobile && collapsible !== "none" && ( + + )} + + {children} + + + ); + } + + return ( + <> + {/* Keep the gap while the modal is open so the page does not reflow. */} +
+ {!openMobile && ( +
+ {children} +
+ )} +
+ + + {/* Only one content tree is mounted: no duplicate menus or queries. */} + {openMobile && {children}} + + + ); +} diff --git a/frontend/src/components/workspace/workspace-sidebar-trigger.tsx b/frontend/src/components/workspace/workspace-sidebar-trigger.tsx new file mode 100644 index 0000000000..29924b27b4 --- /dev/null +++ b/frontend/src/components/workspace/workspace-sidebar-trigger.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useSidebar } from "@/components/ui/sidebar"; +import { cn } from "@/lib/utils"; + +export function WorkspaceSidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { isMobile, open, openMobile, toggleSidebar } = useSidebar(); + const expanded = isMobile ? openMobile : open; + + return ( + + ); +} diff --git a/frontend/src/components/workspace/workspace-sidebar.tsx b/frontend/src/components/workspace/workspace-sidebar.tsx index 1821501c81..f41bbad3d7 100644 --- a/frontend/src/components/workspace/workspace-sidebar.tsx +++ b/frontend/src/components/workspace/workspace-sidebar.tsx @@ -1,7 +1,7 @@ "use client"; import { - Sidebar, + type Sidebar, SidebarHeader, SidebarContent, SidebarFooter, @@ -18,37 +18,50 @@ import { ThreadDeleteDialogProvider } from "./thread-delete-dialog"; import { WorkspaceHeader } from "./workspace-header"; import { WorkspaceNavChatList } from "./workspace-nav-chat-list"; import { WorkspaceNavMenu } from "./workspace-nav-menu"; +import { + WorkspaceSidebarFrame, + useWorkspaceSidebarExpanded, +} from "./workspace-sidebar-frame"; export function WorkspaceSidebar({ ...props }: React.ComponentProps) { - const { open: isSidebarOpen } = useSidebar(); // One activity poll per workspace (off unless the Gateway offers it): new // and changed server-created threads reach the thread lists without a // reload, and a read on another device clears their dots here. useThreadActivity(); return ( - - - - - - - - - {isSidebarOpen && ( - <> - - - - )} - - - - - - + + + ); } + +function WorkspaceSidebarContents() { + const isSidebarOpen = useWorkspaceSidebarExpanded(); + const { isMobile } = useSidebar(); + return ( + <> + + + + + + + + {isSidebarOpen && ( + <> + + + + )} + + + + + {!isMobile && } + + ); +} diff --git a/frontend/tests/e2e/sidebar.spec.ts b/frontend/tests/e2e/sidebar.spec.ts index a93c746cab..ded291710a 100644 --- a/frontend/tests/e2e/sidebar.spec.ts +++ b/frontend/tests/e2e/sidebar.spec.ts @@ -1,8 +1,341 @@ +import { writeFile } from "node:fs/promises"; + import { expect, test } from "@playwright/test"; -import { mockLangGraphAPI } from "./utils/mock-api"; +import { MOCK_THREAD_ID, mockLangGraphAPI } from "./utils/mock-api"; test.describe("Sidebar navigation", () => { + test.describe("collapsed mobile navigation", () => { + test.use({ hasTouch: true }); + for (const width of [390, 767]) { + test(`collapsed sidebar remains usable at ${width}px`, async ({ + page, + context, + baseURL, + }) => { + await context.addCookies([ + { name: "sidebar_state", value: "false", url: baseURL! }, + ]); + await page.setViewportSize({ width, height: 812 }); + mockLangGraphAPI(page, { + threads: [ + { thread_id: MOCK_THREAD_ID, title: "Existing conversation" }, + ], + }); + await page.goto("/workspace/chats/new"); + await expect(page.getByRole("textbox").first()).toBeVisible(); + + const screenshot = test + .info() + .outputPath("collapsed-mobile-sidebar.png"); + await page.screenshot({ path: screenshot }); + await test.info().attach("collapsed-mobile-sidebar", { + path: screenshot, + contentType: "image/png", + }); + const sidebar = page.locator("[data-sidebar='sidebar']:visible"); + await expect( + sidebar.locator("a[href='/workspace/chats']"), + ).toBeVisible(); + await expect( + sidebar.locator("a[href='/workspace/agents']"), + ).toBeVisible(); + const trigger = sidebar.locator("[data-sidebar='trigger']"); + await expect(trigger).toBeVisible(); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); + await expect(trigger).toHaveAttribute("aria-haspopup", "dialog"); + await trigger.tap(); + + const drawer = page.locator( + "[data-mobile='true'][data-sidebar='sidebar']", + ); + await expect(drawer).toBeVisible(); + const drawerTrigger = drawer.locator("[data-sidebar='trigger']"); + await expect(drawerTrigger).toHaveAttribute("aria-expanded", "true"); + await expect(drawerTrigger).toHaveAttribute("aria-haspopup", "dialog"); + await expect( + drawer.getByText("Existing conversation", { exact: true }), + ).toBeVisible(); + await expect( + drawer.getByTestId("projects-display-mode-toggle"), + ).toBeVisible(); + await expect( + drawer.getByText("Settings and more", { exact: true }), + ).toBeVisible(); + await expect( + page.locator("a[href='/workspace/agents']:visible"), + ).toHaveCount(1); + await page.keyboard.press("Escape"); + + await expect(drawer).toBeHidden(); + await expect( + sidebar.locator("a[href='/workspace/agents']"), + ).toBeVisible(); + await expect(sidebar.locator("[data-sidebar='trigger']")).toBeFocused(); + await expect( + sidebar.locator("[data-sidebar='trigger']"), + ).toHaveAttribute("aria-expanded", "false"); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth)) + .toBeLessThanOrEqual(width); + expect( + (await context.cookies()).find( + (cookie) => cookie.name === "sidebar_state", + )?.value, + ).toBe("false"); + + await sidebar.locator("a[href='/workspace/agents']").tap(); + await expect(page).toHaveURL(/\/workspace\/agents$/); + await page.setViewportSize({ width: 768, height: 812 }); + await expect( + page.locator("[data-slot='sidebar'][data-state='collapsed']"), + ).toBeVisible(); + await expect( + page.locator("[data-mobile='true'][data-sidebar='sidebar']"), + ).toBeHidden(); + }); + } + + test("mobile drawer closes after tapping a navigation link", async ({ + page, + context, + baseURL, + }) => { + await context.addCookies([ + { name: "sidebar_state", value: "false", url: baseURL! }, + ]); + await page.setViewportSize({ width: 390, height: 812 }); + mockLangGraphAPI(page); + await page.goto("/workspace/chats/new"); + await expect(page.getByRole("textbox").first()).toBeVisible(); + + const sidebar = page.locator("[data-sidebar='sidebar']:visible"); + await sidebar.locator("[data-sidebar='trigger']").tap(); + const drawer = page.locator( + "[data-mobile='true'][data-sidebar='sidebar']", + ); + await expect(drawer).toBeVisible(); + + await drawer.locator("a[href='/workspace/agents']").tap(); + await expect(page).toHaveURL(/\/workspace\/agents$/); + await expect(drawer).toBeHidden(); + await expect(sidebar.locator("a[href='/workspace/chats']")).toBeVisible(); + await expect(sidebar.locator("[data-sidebar='trigger']")).toHaveAttribute( + "aria-expanded", + "false", + ); + + await sidebar.locator("a[href='/workspace/chats']").tap(); + await expect(page).toHaveURL(/\/workspace\/chats$/); + expect( + (await context.cookies()).find( + (cookie) => cookie.name === "sidebar_state", + )?.value, + ).toBe("false"); + }); + + for (const preference of ["false", "true"]) { + test(`chat header reflects the drawer state and restores focus with sidebar_state=${preference}`, async ({ + page, + context, + baseURL, + }) => { + await context.addCookies([ + { name: "sidebar_state", value: preference, url: baseURL! }, + ]); + await page.setViewportSize({ width: 390, height: 812 }); + mockLangGraphAPI(page); + await page.goto("/workspace/chats/new"); + await expect(page.getByRole("textbox").first()).toBeVisible(); + + const pageTrigger = page.locator("header [data-sidebar='trigger']"); + await expect(pageTrigger).toHaveAttribute("aria-expanded", "false"); + await expect(pageTrigger).toHaveAttribute("aria-haspopup", "dialog"); + await expect( + pageTrigger.locator(".lucide-panel-left-open"), + ).toBeVisible(); + await pageTrigger.tap(); + + const drawer = page.getByRole("dialog", { name: "Sidebar" }); + const drawerTrigger = drawer.locator("[data-sidebar='trigger']"); + await expect(drawerTrigger).toHaveAttribute("aria-expanded", "true"); + await expect( + drawerTrigger.locator(".lucide-panel-left-close"), + ).toBeVisible(); + await drawerTrigger.tap(); + await expect(drawer).toHaveCount(0); + await expect(pageTrigger).toBeFocused(); + await expect(pageTrigger).toHaveAttribute("aria-expanded", "false"); + + await pageTrigger.tap(); + await expect(drawer).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(drawer).toHaveCount(0); + await expect(pageTrigger).toBeFocused(); + }); + + test(`drawer navigation preserves Chats search focus with sidebar_state=${preference}`, async ({ + page, + context, + baseURL, + }) => { + await context.addCookies([ + { name: "sidebar_state", value: preference, url: baseURL! }, + ]); + await page.setViewportSize({ width: 390, height: 812 }); + mockLangGraphAPI(page); + await page.goto("/workspace/chats/new"); + await expect(page.getByRole("textbox").first()).toBeVisible(); + const pageTrigger = page.locator("header [data-sidebar='trigger']"); + await pageTrigger.tap(); + const drawer = page.getByRole("dialog", { name: "Sidebar" }); + await drawer.locator("a[href='/workspace/chats']").tap(); + await expect(page).toHaveURL(/\/workspace\/chats$/); + await expect(drawer).toHaveCount(0); + const search = page.getByRole("searchbox"); + await expect(search).toBeFocused(); + await page.keyboard.type("Find a conversation"); + await expect(search).toHaveValue("Find a conversation"); + }); + } + }); + + test.describe("Agents gallery header", () => { + for (const width of [390, 767, 1280]) { + test.describe(`${width}px`, () => { + test.use({ hasTouch: width < 768 }); + + for (const preference of ["false", "true"]) { + test(`keeps header actions usable with sidebar_state=${preference}`, async ({ + page, + context, + baseURL, + }) => { + await context.addCookies([ + { name: "sidebar_state", value: preference, url: baseURL! }, + ]); + await page.setViewportSize({ width, height: 812 }); + mockLangGraphAPI(page); + await page.goto("/workspace/agents"); + if (width < 768 && preference === "false") { + await expect( + page.locator( + "[data-sidebar='sidebar'][data-collapsible='icon']:visible", + ), + ).toBeVisible(); + } + + const title = page.getByRole("heading", { level: 1 }); + const description = title.locator("..").locator("p"); + const importButton = page.getByRole("button", { + name: "Import Agent", + exact: true, + }); + const newAgentButtons = page.getByRole("button", { + name: "New Agent", + exact: true, + }); + const headerNewAgentButton = newAgentButtons.first(); + await expect(title).toBeVisible(); + // The mocked empty gallery also exposes a second New Agent button. + await expect(newAgentButtons).toHaveCount(2); + await page.evaluate(() => + document.fonts.ready.then(() => undefined), + ); + + try { + const content = await page + .locator("[data-slot='sidebar-inset']") + .boundingBox(); + expect(content).not.toBeNull(); + const elements = [ + { name: "title", locator: title }, + { name: "description", locator: description }, + { name: "import", locator: importButton }, + { name: "newAgent", locator: headerNewAgentButton }, + ]; + const geometry = await Promise.all( + elements.map(async ({ name, locator }) => ({ + name, + box: await locator.boundingBox(), + })), + ); + const geometryPath = test + .info() + .outputPath(`agents-header-${width}-${preference}.json`); + await writeFile( + geometryPath, + JSON.stringify({ + viewport: width, + documentWidth: await page.evaluate( + () => document.documentElement.scrollWidth, + ), + content, + elements: geometry, + }), + "utf8", + ); + await test.info().attach("agents-header-geometry", { + path: geometryPath, + contentType: "application/json", + }); + for (const { locator } of elements) { + await expect(locator).toBeVisible(); + const box = await locator.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(content!.x - 1); + expect(box!.x + box!.width).toBeLessThanOrEqual(width + 1); + } + await expect + .poll(() => + page.evaluate(() => document.documentElement.scrollWidth), + ) + .toBeLessThanOrEqual(width); + if (width === 390) { + const descriptionBox = await description.boundingBox(); + // A fully visible but word-wide description is still unusable. + expect(descriptionBox!.width).toBeGreaterThan(width / 2); + } + } finally { + const screenshot = test + .info() + .outputPath(`agents-header-${width}-${preference}.png`); + await page.screenshot({ + path: screenshot, + animations: "disabled", + }); + await test.info().attach("agents-header", { + path: screenshot, + contentType: "image/png", + }); + } + + const fileChooserOpened = page.waitForEvent("filechooser"); + if (width < 768) { + await importButton.tap(); + } else { + await importButton.click(); + } + const fileChooser = await fileChooserOpened; + await fileChooser.setFiles([]); + expect( + (await context.cookies()).find( + (cookie) => cookie.name === "sidebar_state", + )?.value, + ).toBe(preference); + + if (width < 768) { + await headerNewAgentButton.tap(); + } else { + await headerNewAgentButton.click(); + } + await expect(page).toHaveURL(/\/workspace\/agents\/new$/); + }); + } + }); + } + }); + test("sidebar contains Chats and Agents nav links", async ({ page }) => { mockLangGraphAPI(page); diff --git a/frontend/tests/unit/components/workspace/workspace-sidebar-frame.dom.test.tsx b/frontend/tests/unit/components/workspace/workspace-sidebar-frame.dom.test.tsx new file mode 100644 index 0000000000..1291515de3 --- /dev/null +++ b/frontend/tests/unit/components/workspace/workspace-sidebar-frame.dom.test.tsx @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it, rs } from "@rstest/core"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import Link from "next/link"; + +import { + SidebarProvider, + SidebarTrigger, + useSidebar, +} from "@/components/ui/sidebar"; +import { WorkspaceHeader } from "@/components/workspace/workspace-header"; +import { + WorkspaceSidebarFrame, + useWorkspaceSidebarExpanded, +} from "@/components/workspace/workspace-sidebar-frame"; + +const { mobile, navigation } = rs.hoisted(() => ({ + mobile: { value: true }, + navigation: { pathname: "/workspace/chats/new" }, +})); +rs.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => mobile.value })); +rs.mock("next/navigation", () => ({ + usePathname: () => navigation.pathname, +})); +rs.mock("@/core/i18n/hooks", () => ({ + useI18n: () => ({ t: { sidebar: { newChat: "New chat" } } }), +})); +rs.mock("@/env", () => ({ + env: { NEXT_PUBLIC_STATIC_WEBSITE_ONLY: "false" }, +})); + +function Contents() { + const expanded = useWorkspaceSidebarExpanded(); + const { toggleSidebar } = useSidebar(); + return ( + <> + + Agents + event.preventDefault()}> + Chats + + {expanded &&

Projects and recent chats

} + + ); +} + +function DesktopState() { + const { open } = useSidebar(); + return {String(open)}; +} + +function Harness({ + defaultOpen = false, + collapsible = "icon", +}: { + defaultOpen?: boolean; + collapsible?: "icon" | "offcanvas" | "none"; +}) { + return ( + + + + + + + {navigation.pathname === "/workspace/chats" && ( + + )} + + ); +} + +afterEach(() => { + cleanup(); + mobile.value = true; + navigation.pathname = "/workspace/chats/new"; + document.cookie = "sidebar_state=; path=/; max-age=0"; +}); + +describe("workspace mobile sidebar", () => { + it("shows the drawer state in the sidebar header icon", async () => { + render( + + + + + , + ); + const railTrigger = screen.getByRole("button", { name: "Toggle Sidebar" }); + expect(railTrigger.querySelector(".lucide-panel-left-open")).not.toBeNull(); + fireEvent.click(railTrigger); + const dialog = await screen.findByRole("dialog"); + const drawerTrigger = within(dialog).getByRole("button", { + name: "Toggle Sidebar", + }); + expect(drawerTrigger.getAttribute("aria-expanded")).toBe("true"); + expect( + drawerTrigger.querySelector(".lucide-panel-left-close"), + ).not.toBeNull(); + }); + + it("returns focus to the page trigger that opened the drawer", async () => { + render(); + const pageTrigger = screen.getByRole("button", { name: "Toggle Sidebar" }); + pageTrigger.focus(); + fireEvent.click(pageTrigger); + await screen.findByRole("dialog"); + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + await waitFor(() => expect(document.activeElement).toBe(pageTrigger)); + }); + + it("preserves destination focus when navigation closes the drawer", async () => { + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Toggle navigation" })); + await screen.findByRole("dialog"); + + fireEvent.click(screen.getByRole("link", { name: "Chats" })); + navigation.pathname = "/workspace/chats"; + view.rerender(); + + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("textbox", { name: "Search chats" }), + ), + ); + }); + + it("uses the standard mobile drawer width with a collapsed desktop preference", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Toggle Sidebar" })); + const standardDrawer = await screen.findByRole("dialog"); + const standardWidth = + standardDrawer.style.getPropertyValue("--sidebar-width"); + expect(standardWidth).not.toBe(""); + cleanup(); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Toggle navigation" })); + const drawer = await screen.findByRole("dialog"); + expect(drawer.style.getPropertyValue("--sidebar-width")).toBe( + standardWidth, + ); + }); + + for (const defaultOpen of [false, true]) { + it(`closes the mobile drawer on navigation with desktop open=${defaultOpen}`, async () => { + const view = render(); + fireEvent.click( + screen.getByRole("button", { + name: defaultOpen ? "Toggle Sidebar" : "Toggle navigation", + }), + ); + await screen.findByRole("dialog"); + + navigation.pathname = "/workspace/agents"; + view.rerender(); + + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(screen.getByTestId("desktop-state").textContent).toBe( + String(defaultOpen), + ); + expect(document.cookie).not.toContain("sidebar_state="); + }); + } + + it("keeps a collapsed rail and opens full content without changing the desktop preference", async () => { + render(); + expect(screen.getByRole("link", { name: "Agents" })).toBeDefined(); + expect(screen.queryByText("Projects and recent chats")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Toggle navigation" })); + + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("Projects and recent chats")).toBeDefined(); + expect(screen.getAllByRole("link", { name: "Agents" })).toHaveLength(1); + expect(screen.getByTestId("desktop-state").textContent).toBe("false"); + expect(document.cookie).not.toContain("sidebar_state="); + + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(screen.queryByText("Projects and recent chats")).toBeNull(); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Toggle navigation" }), + ), + ); + }); + + it("restores the desktop state when leaving the mobile breakpoint", async () => { + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Toggle navigation" })); + await screen.findByRole("dialog"); + mobile.value = false; + view.rerender(); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(screen.getByTestId("desktop-state").textContent).toBe("false"); + expect(screen.queryByText("Projects and recent chats")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Toggle navigation" })); + expect(screen.getByTestId("desktop-state").textContent).toBe("true"); + expect(screen.getByText("Projects and recent chats")).toBeDefined(); + }); + + for (const collapsible of ["icon", "offcanvas"] as const) { + it(`keeps the standard mobile drawer for ${collapsible} with an expanded desktop preference`, async () => { + render(); + expect(screen.queryByRole("link", { name: "Agents" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Toggle Sidebar" })); + const dialog = await screen.findByRole("dialog"); + expect( + within(dialog).getByText("Projects and recent chats"), + ).toBeDefined(); + expect(screen.getByTestId("desktop-state").textContent).toBe("true"); + }); + } + + it("leaves a non-collapsible sidebar visible on mobile", () => { + render(); + expect(screen.getByRole("link", { name: "Agents" })).toBeDefined(); + expect(screen.getByText("Projects and recent chats")).toBeDefined(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +});