fix(frontend): keep collapsed sidebar navigation visible on mobile (#7198)

* fix(frontend): keep collapsed sidebar navigation visible on mobile

* fix(frontend): address mobile sidebar review feedback

* fix(ci): keep frontend agent guidance within size budget

* fix(frontend): align mobile sidebar focus and trigger state

* fix(frontend): keep Agents header actions within mobile viewport

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
ShallAbby 2026-10-22 15:13:01 +08:00 committed by GitHub
parent f3efd23329
commit c11ba84708
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 891 additions and 65 deletions

View File

@ -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**

View File

@ -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

View File

@ -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",
)}
>
<SidebarTrigger className="md:hidden" />
<WorkspaceSidebarTrigger className="md:hidden" />
{/* Agent badge */}
<div className="flex min-w-0 shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />

View File

@ -78,14 +78,14 @@ export function AgentGallery() {
return (
<div className="flex size-full flex-col">
{/* Page header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-4 border-b px-6 py-4">
<div className="min-w-0">
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
{t.agents.description}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex max-w-full flex-wrap items-center gap-2">
<input
ref={fileInputRef}
className="hidden"

View File

@ -6,8 +6,8 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState, useSyncExternalStore } from "react";
import { Input } from "@/components/ui/input";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { WorkspaceSidebarTrigger } from "@/components/workspace/workspace-sidebar-trigger";
import { useI18n } from "@/core/i18n/hooks";
const PluginGallery = dynamic(() =>
@ -51,7 +51,7 @@ export function CapabilityCenter() {
return (
<div className="bg-background flex h-full min-h-0 flex-col">
<div className="text-muted-foreground flex h-14 shrink-0 items-center gap-3 border-b px-4 text-xs md:px-8">
<SidebarTrigger className="md:hidden" />
<WorkspaceSidebarTrigger className="md:hidden" />
<span>{t.breadcrumb.workspace}</span>
<span className="opacity-40">/</span>
<span className="text-foreground">{t.capabilities.title}</span>

View File

@ -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();

View File

@ -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 && <SidebarTrigger className="md:hidden" />}
{!isMock && <WorkspaceSidebarTrigger className="md:hidden" />}
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm font-medium">
<ThreadTitle
threadId={threadId}

View File

@ -3,7 +3,7 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { WorkspaceSidebarTrigger } from "@/components/workspace/workspace-sidebar-trigger";
import { useFrontendExtensions } from "@/core/extensions/hooks";
import { pluginPages, pluginPageTitle } from "@/core/extensions/pages";
import { useI18n } from "@/core/i18n/hooks";
@ -29,7 +29,7 @@ export function PluginPage({
return (
<div className="bg-background flex h-full min-h-0 flex-col">
<div className="text-muted-foreground flex h-14 shrink-0 items-center gap-3 border-b px-4 text-xs md:px-8">
<SidebarTrigger className="md:hidden" />
<WorkspaceSidebarTrigger className="md:hidden" />
<span>{t.breadcrumb.workspace}</span>
<span>/</span>
<span>{title}</span>

View File

@ -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/<section>/page.tsx)
// and can therefore be linked to from the breadcrumb.
const LINKABLE_SECTIONS: Record<string, true> = {
@ -60,7 +60,7 @@ export function WorkspaceHeader({
{...props}
>
<div className="flex min-w-0 items-center gap-2 px-2 sm:px-4">
<SidebarTrigger className="md:hidden" />
<WorkspaceSidebarTrigger className="md:hidden" />
<Breadcrumb>
<BreadcrumbList>
{segments?.[0] && (

View File

@ -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 ? (
<div className="group-has-data-[collapsible=icon]/sidebar-wrapper:-translate-y flex w-full cursor-pointer items-center justify-center">
<div className="text-primary block pt-1 font-serif group-hover/workspace-header:hidden">
<div
className={cn(
"text-primary block pt-1 font-serif group-hover/workspace-header:hidden",
isMobile && "hidden",
)}
>
DF
</div>
<SidebarTrigger className="hidden pl-2 group-hover/workspace-header:block" />
<WorkspaceSidebarTrigger
className={cn(
"pl-2",
!isMobile && "hidden group-hover/workspace-header:block",
)}
/>
</div>
) : (
<div className="flex items-center justify-between gap-2">
@ -45,7 +58,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
DeerFlow
</div>
)}
<SidebarTrigger />
<WorkspaceSidebarTrigger />
</div>
)}
</div>

View File

@ -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(() => {

View File

@ -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<boolean | undefined>(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 (
<ExpandedContext.Provider value={expanded}>
<div
className="contents"
onClickCapture={(event) => {
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<HTMLAnchorElement>("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}
</div>
</ExpandedContext.Provider>
);
}
function RestoreSidebarFocus({
railRef,
pathnameRef,
}: {
railRef: RefObject<HTMLDivElement | null>;
pathnameRef: RefObject<string | null>;
}) {
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<HTMLButtonElement>(
'[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<typeof Sidebar>) {
const { isMobile, open, openMobile, setOpenMobile } = useSidebar();
const pathname = usePathname();
const pathnameRef = useRef(pathname);
const railRef = useRef<HTMLDivElement>(null);
useEffect(() => {
pathnameRef.current = pathname;
setOpenMobile(false);
}, [pathname, setOpenMobile]);
if (!isMobile || collapsible !== "icon" || open) {
return (
<Sidebar
collapsible={collapsible}
variant={variant}
side={side}
className={className}
style={style}
{...props}
>
{isMobile && collapsible !== "none" && (
<RestoreSidebarFocus railRef={railRef} pathnameRef={pathnameRef} />
)}
<Presentation
expanded={isMobile && collapsible !== "none" ? openMobile : undefined}
>
{children}
</Presentation>
</Sidebar>
);
}
return (
<>
{/* Keep the gap while the modal is open so the page does not reflow. */}
<div
className="w-(--sidebar-width-icon) shrink-0"
style={style}
{...props}
>
{!openMobile && (
<div
ref={railRef}
data-sidebar="sidebar"
data-slot="sidebar"
data-state="collapsed"
data-collapsible="icon"
data-variant={variant}
data-side={side}
className={cn(
"group bg-sidebar text-sidebar-foreground fixed inset-y-0 z-10 flex h-svh w-(--sidebar-width-icon) flex-col",
side === "left" ? "left-0 border-r" : "right-0 border-l",
className,
)}
>
<Presentation expanded={false}>{children}</Presentation>
</div>
)}
</div>
<Sidebar side={side} variant={variant} collapsible={collapsible}>
<RestoreSidebarFocus railRef={railRef} pathnameRef={pathnameRef} />
{/* Only one content tree is mounted: no duplicate menus or queries. */}
{openMobile && <Presentation expanded>{children}</Presentation>}
</Sidebar>
</>
);
}

View File

@ -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<typeof Button>) {
const { isMobile, open, openMobile, toggleSidebar } = useSidebar();
const expanded = isMobile ? openMobile : open;
return (
<Button
type="button"
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7 opacity-50 hover:opacity-100", className)}
{...props}
aria-expanded={expanded}
aria-haspopup={isMobile ? "dialog" : undefined}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
// Touch browsers need an explicit focus target for drawer dismissal.
if (isMobile && !openMobile) event.currentTarget.focus();
toggleSidebar();
}}
>
{expanded ? <PanelLeftCloseIcon /> : <PanelLeftOpenIcon />}
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}

View File

@ -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<typeof Sidebar>) {
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 (
<ThreadDeleteDialogProvider>
<Sidebar variant="sidebar" collapsible="icon" {...props}>
<SidebarHeader className="py-0">
<WorkspaceHeader />
</SidebarHeader>
<SidebarContent>
<WorkspaceNavChatList />
<PluginNavigation />
<WorkspaceChannelsList />
{isSidebarOpen && (
<>
<ProjectsSection />
<RecentChatList />
</>
)}
</SidebarContent>
<SidebarFooter>
<WorkspaceNavMenu />
</SidebarFooter>
<SidebarRail />
</Sidebar>
<WorkspaceSidebarFrame variant="sidebar" collapsible="icon" {...props}>
<WorkspaceSidebarContents />
</WorkspaceSidebarFrame>
</ThreadDeleteDialogProvider>
);
}
function WorkspaceSidebarContents() {
const isSidebarOpen = useWorkspaceSidebarExpanded();
const { isMobile } = useSidebar();
return (
<>
<SidebarHeader className="py-0">
<WorkspaceHeader />
</SidebarHeader>
<SidebarContent>
<WorkspaceNavChatList />
<PluginNavigation />
<WorkspaceChannelsList />
{isSidebarOpen && (
<>
<ProjectsSection />
<RecentChatList />
</>
)}
</SidebarContent>
<SidebarFooter>
<WorkspaceNavMenu />
</SidebarFooter>
{!isMobile && <SidebarRail />}
</>
);
}

View File

@ -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);

View File

@ -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 (
<>
<button data-sidebar="trigger" onClick={toggleSidebar}>
Toggle navigation
</button>
<a href="#agents">Agents</a>
<Link href="/workspace/chats" onClick={(event) => event.preventDefault()}>
Chats
</Link>
{expanded && <p>Projects and recent chats</p>}
</>
);
}
function DesktopState() {
const { open } = useSidebar();
return <output data-testid="desktop-state">{String(open)}</output>;
}
function Harness({
defaultOpen = false,
collapsible = "icon",
}: {
defaultOpen?: boolean;
collapsible?: "icon" | "offcanvas" | "none";
}) {
return (
<SidebarProvider defaultOpen={defaultOpen}>
<WorkspaceSidebarFrame collapsible={collapsible}>
<Contents />
</WorkspaceSidebarFrame>
<DesktopState />
<SidebarTrigger />
{navigation.pathname === "/workspace/chats" && (
<input aria-label="Search chats" autoFocus />
)}
</SidebarProvider>
);
}
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(
<SidebarProvider defaultOpen={false}>
<WorkspaceSidebarFrame>
<WorkspaceHeader />
</WorkspaceSidebarFrame>
</SidebarProvider>,
);
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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness defaultOpen />);
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(<Harness />);
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(<Harness defaultOpen={defaultOpen} />);
fireEvent.click(
screen.getByRole("button", {
name: defaultOpen ? "Toggle Sidebar" : "Toggle navigation",
}),
);
await screen.findByRole("dialog");
navigation.pathname = "/workspace/agents";
view.rerender(<Harness defaultOpen={defaultOpen} />);
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(<Harness />);
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(<Harness />);
fireEvent.click(screen.getByRole("button", { name: "Toggle navigation" }));
await screen.findByRole("dialog");
mobile.value = false;
view.rerender(<Harness />);
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(<Harness defaultOpen collapsible={collapsible} />);
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(<Harness defaultOpen collapsible="none" />);
expect(screen.getByRole("link", { name: "Agents" })).toBeDefined();
expect(screen.getByText("Projects and recent chats")).toBeDefined();
expect(screen.queryByRole("dialog")).toBeNull();
});
});