mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
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:
parent
f3efd23329
commit
c11ba84708
10
README.md
10
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**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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] && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
184
frontend/src/components/workspace/workspace-sidebar-frame.tsx
Normal file
184
frontend/src/components/workspace/workspace-sidebar-frame.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user