mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +00:00
Fix(frontend): Fix mobile workspace and accessibility blockers (#3740)
* fix(frontend): address mobile workspace polish blockers * fix(frontend): prevent mobile landing overflow * fix(frontend): keep landing sections within mobile viewport Section titles used a fixed text-5xl with no word breaking, and the <section> flex items had default min-width:auto, so wider Linux font metrics in CI pushed content past the viewport (scrollWidth 345 > 320). Make titles/subtitles responsive with break-words, constrain section width with min-w-0, and add an overflow-x-clip guard on the page root. * fix(frontend): address review feedback on mobile landing PR - add hamburger Sheet nav below sm: so mobile users keep docs/blog access - switch useIsMobile to useSyncExternalStore to avoid hydration swap flash - memoize artifactContent so it isn't rebuilt on every streamed token - use slot-based key in HeroWordRotate; drop flex-wrap so SuperAgent never orphans at 320px - delete unused word-rotate.tsx dead code - move section gutter to <main> for a uniform mobile padding contract - harden e2e: per-viewport overflow tests, locale-stable artifact selector, focus-ring asserts light vs dark differ --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
f0f9dd6656
commit
7a985f441c
@ -9,7 +9,7 @@ import { WhatsNewSection } from "@/components/landing/sections/whats-new-section
|
|||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full bg-[#0a0a0a]">
|
<div className="min-h-screen w-full overflow-x-clip bg-[#0a0a0a]">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex w-full flex-col">
|
<main className="flex w-full flex-col">
|
||||||
<Hero />
|
<Hero />
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { getI18n } from "@/core/i18n/server";
|
|||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { MobileNav } from "./mobile-nav";
|
||||||
|
|
||||||
export type HeaderProps = {
|
export type HeaderProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
homeURL?: string;
|
homeURL?: string;
|
||||||
@ -21,20 +23,21 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
|
|||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
"container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between backdrop-blur-xs",
|
"container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between gap-3 px-4 backdrop-blur-xs",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex min-w-0 items-center gap-6">
|
||||||
<a
|
<a
|
||||||
href={homeURL ?? "https://github.com/bytedance/deer-flow"}
|
href={homeURL ?? "https://github.com/bytedance/deer-flow"}
|
||||||
target={isExternalHome ? "_blank" : "_self"}
|
target={isExternalHome ? "_blank" : "_self"}
|
||||||
rel={isExternalHome ? "noopener noreferrer" : undefined}
|
rel={isExternalHome ? "noopener noreferrer" : undefined}
|
||||||
|
className="font-serif text-xl whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<h1 className="font-serif text-xl">DeerFlow</h1>
|
DeerFlow
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium">
|
<nav className="ml-auto hidden items-center gap-5 text-sm font-medium sm:flex md:mr-8 md:gap-8">
|
||||||
<Link
|
<Link
|
||||||
href={`/${lang}/docs`}
|
href={`/${lang}/docs`}
|
||||||
className="text-secondary-foreground hover:text-foreground transition-colors"
|
className="text-secondary-foreground hover:text-foreground transition-colors"
|
||||||
@ -68,12 +71,18 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<GitHubLogoIcon className="size-4" />
|
<GitHubLogoIcon className="size-4" />
|
||||||
Star on GitHub
|
<span className="hidden sm:inline">Star on GitHub</span>
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||||
env.GITHUB_OAUTH_TOKEN && <StarCounter />}
|
env.GITHUB_OAUTH_TOKEN && <StarCounter />}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<MobileNav
|
||||||
|
links={[
|
||||||
|
{ href: `/${lang}/docs`, label: t.home.docs },
|
||||||
|
{ href: "/blog/posts", label: t.home.blog },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<hr className="from-border/0 via-border/70 to-border/0 absolute top-16 right-0 left-0 z-10 m-0 h-px w-full border-none bg-linear-to-r" />
|
<hr className="from-border/0 via-border/70 to-border/0 absolute top-16 right-0 left-0 z-10 m-0 h-px w-full border-none bg-linear-to-r" />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,15 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronRightIcon } from "lucide-react";
|
import { ChevronRightIcon } from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { AuroraText } from "@/components/ui/aurora-text";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
||||||
import Galaxy from "@/components/ui/galaxy";
|
import Galaxy from "@/components/ui/galaxy";
|
||||||
import { WordRotate } from "@/components/ui/word-rotate";
|
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const HERO_WORDS = [
|
||||||
|
"Deep Research",
|
||||||
|
"Collect Data",
|
||||||
|
"Analyze Data",
|
||||||
|
"Generate Webpages",
|
||||||
|
"Vibe Coding",
|
||||||
|
"Generate Slides",
|
||||||
|
"Generate Images",
|
||||||
|
"Generate Podcasts",
|
||||||
|
"Generate Videos",
|
||||||
|
"Generate Songs",
|
||||||
|
"Organize Emails",
|
||||||
|
"Do Anything",
|
||||||
|
"Learn Anything",
|
||||||
|
];
|
||||||
|
|
||||||
export function Hero({ className }: { className?: string }) {
|
export function Hero({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -36,27 +54,14 @@ export function Hero({ className }: { className?: string }) {
|
|||||||
maxOpacity={0.3}
|
maxOpacity={0.3}
|
||||||
flickerChance={0.25}
|
flickerChance={0.25}
|
||||||
/>
|
/>
|
||||||
<div className="container-md relative z-10 mx-auto flex h-screen flex-col items-center justify-center">
|
<div className="container-md relative z-10 mx-auto flex min-h-[92svh] flex-col items-center justify-center px-4 pt-20 pb-14">
|
||||||
<h1 className="flex items-center gap-2 text-4xl font-bold md:text-6xl">
|
<h1 className="text-center text-5xl leading-tight font-bold break-words md:text-6xl">
|
||||||
<WordRotate
|
DeerFlow
|
||||||
words={[
|
|
||||||
"Deep Research",
|
|
||||||
"Collect Data",
|
|
||||||
"Analyze Data",
|
|
||||||
"Generate Webpages",
|
|
||||||
"Vibe Coding",
|
|
||||||
"Generate Slides",
|
|
||||||
"Generate Images",
|
|
||||||
"Generate Podcasts",
|
|
||||||
"Generate Videos",
|
|
||||||
"Generate Songs",
|
|
||||||
"Organize Emails",
|
|
||||||
"Do Anything",
|
|
||||||
"Learn Anything",
|
|
||||||
]}
|
|
||||||
/>{" "}
|
|
||||||
<div>with DeerFlow</div>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
<div className="mt-3 flex w-full max-w-full min-w-0 items-center justify-center gap-x-2 text-center text-2xl font-semibold md:text-4xl">
|
||||||
|
<HeroWordRotate words={HERO_WORDS} />
|
||||||
|
<span className="whitespace-nowrap">SuperAgent</span>
|
||||||
|
</div>
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
|
||||||
<a
|
<a
|
||||||
href="https://byteplus.com"
|
href="https://byteplus.com"
|
||||||
@ -69,17 +74,13 @@ export function Hero({ className }: { className?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<p className="text-muted-foreground mt-8 scale-105 text-center text-2xl text-shadow-sm">
|
<p className="text-muted-foreground mt-8 max-w-4xl text-center text-base leading-7 text-shadow-sm sm:text-xl md:text-2xl">
|
||||||
An open-source SuperAgent harness that researches, codes, and creates.
|
An open-source SuperAgent harness that researches, codes, and creates.
|
||||||
With
|
With the help of sandboxes, memories, tools, skills and subagents, it
|
||||||
<br />
|
handles different levels of tasks that could take minutes to hours.
|
||||||
the help of sandboxes, memories, tools, skills and subagents, it
|
|
||||||
handles
|
|
||||||
<br />
|
|
||||||
different levels of tasks that could take minutes to hours.
|
|
||||||
</p>
|
</p>
|
||||||
<Link href="/workspace">
|
<Link href="/workspace">
|
||||||
<Button className="size-lg mt-8 scale-108" size="lg">
|
<Button className="mt-8 h-11 px-5" size="lg">
|
||||||
<span className="text-md">Get Started with 2.0</span>
|
<span className="text-md">Get Started with 2.0</span>
|
||||||
<ChevronRightIcon className="size-4" />
|
<ChevronRightIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -89,6 +90,47 @@ export function Hero({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HeroWordRotate({
|
||||||
|
words,
|
||||||
|
duration = 2200,
|
||||||
|
}: {
|
||||||
|
words: string[];
|
||||||
|
duration?: number;
|
||||||
|
}) {
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setIndex((prevIndex) => (prevIndex + 1) % words.length);
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [words, duration]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative max-w-full min-w-0 overflow-hidden py-2">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
className="max-w-full"
|
||||||
|
initial={{ opacity: 0, y: -50, filter: "blur(16px)" }}
|
||||||
|
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||||
|
exit={{ opacity: 0, y: 50, filter: "blur(16px)" }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<AuroraText
|
||||||
|
className="max-w-full [overflow-wrap:anywhere] whitespace-normal"
|
||||||
|
speed={3}
|
||||||
|
colors={["#efefbb", "#e9c665", "#e3a812"]}
|
||||||
|
>
|
||||||
|
{words[index]}
|
||||||
|
</AuroraText>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BytePlusIcon(props: React.SVGProps<SVGSVGElement>) {
|
function BytePlusIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
49
frontend/src/components/landing/mobile-nav.tsx
Normal file
49
frontend/src/components/landing/mobile-nav.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MenuIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
|
||||||
|
export type MobileNavLink = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MobileNav({ links }: { links: MobileNavLink[] }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetTrigger asChild className="sm:hidden">
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Open menu">
|
||||||
|
<MenuIcon className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" className="w-64">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="font-serif text-xl">DeerFlow</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<nav className="flex flex-col gap-1 px-4 text-base font-medium">
|
||||||
|
{links.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="text-secondary-foreground hover:text-foreground rounded-md py-2 transition-colors"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,18 +12,20 @@ export function Section({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className={cn("mx-auto flex flex-col py-16", className)}>
|
<section
|
||||||
<header className="flex flex-col items-center justify-between">
|
className={cn("mx-auto flex w-full min-w-0 flex-col py-16", className)}
|
||||||
<div className="mb-4 bg-linear-to-r from-white via-gray-200 to-gray-400 bg-clip-text text-center text-5xl font-bold text-transparent">
|
>
|
||||||
|
<header className="flex flex-col items-center justify-between px-4">
|
||||||
|
<div className="mb-4 max-w-full bg-linear-to-r from-white via-gray-200 to-gray-400 bg-clip-text text-center text-3xl font-bold break-words text-transparent sm:text-4xl md:text-5xl">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<div className="text-muted-foreground text-center text-xl">
|
<div className="text-muted-foreground max-w-full text-center text-base break-words sm:text-lg md:text-xl">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main className="mt-4">{children}</main>
|
<main className="mt-4 w-full min-w-0 px-4">{children}</main>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,7 +51,7 @@ export function CaseStudySection({ className }: { className?: string }) {
|
|||||||
title="Case Studies"
|
title="Case Studies"
|
||||||
subtitle="See how DeerFlow is used in the wild"
|
subtitle="See how DeerFlow is used in the wild"
|
||||||
>
|
>
|
||||||
<div className="container-md mt-8 grid grid-cols-1 gap-4 px-4 md:grid-cols-2 md:px-20 lg:grid-cols-3">
|
<div className="container-md mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 md:px-20 lg:grid-cols-3">
|
||||||
{caseStudies.map((caseStudy) => (
|
{caseStudies.map((caseStudy) => (
|
||||||
<Link
|
<Link
|
||||||
key={caseStudy.title}
|
key={caseStudy.title}
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { AnimatePresence, motion, type MotionProps } from "motion/react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { AuroraText } from "./aurora-text";
|
|
||||||
|
|
||||||
interface WordRotateProps {
|
|
||||||
words: string[];
|
|
||||||
duration?: number;
|
|
||||||
motionProps?: MotionProps;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WordRotate({
|
|
||||||
words,
|
|
||||||
duration = 2200,
|
|
||||||
motionProps = {
|
|
||||||
initial: { opacity: 0, y: -50, filter: "blur(16px)" },
|
|
||||||
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
|
|
||||||
exit: { opacity: 0, y: 50, filter: "blur(16px)" },
|
|
||||||
transition: { duration: 0.3, ease: "easeOut" },
|
|
||||||
},
|
|
||||||
className,
|
|
||||||
}: WordRotateProps) {
|
|
||||||
const [index, setIndex] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setIndex((prevIndex) => (prevIndex + 1) % words.length);
|
|
||||||
}, duration);
|
|
||||||
|
|
||||||
// Clean up interval on unmount
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [words, duration]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-hidden py-2">
|
|
||||||
<AnimatePresence mode="popLayout">
|
|
||||||
<motion.h1
|
|
||||||
key={words[index]}
|
|
||||||
className={cn(className)}
|
|
||||||
{...motionProps}
|
|
||||||
>
|
|
||||||
<AuroraText speed={3} colors={["#efefbb", "#e9c665", "#e3a812"]}>
|
|
||||||
{words[index]}
|
|
||||||
</AuroraText>
|
|
||||||
</motion.h1>
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -17,17 +17,19 @@ export const ArtifactTrigger = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Tooltip content="Show artifacts of this conversation">
|
<Tooltip content={t.common.showArtifacts}>
|
||||||
<Button
|
<Button
|
||||||
|
aria-label={t.common.showArtifacts}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
data-testid="artifact-trigger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
sidecar?.close();
|
sidecar?.close();
|
||||||
setArtifactsOpen(true);
|
setArtifactsOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FilesIcon />
|
<FilesIcon />
|
||||||
{t.common.artifacts}
|
<span className="hidden sm:inline">{t.common.artifacts}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,7 +4,15 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -24,6 +32,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
threadId,
|
threadId,
|
||||||
}) => {
|
}) => {
|
||||||
const { thread } = useThread();
|
const { thread } = useThread();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const threadIdRef = useRef(threadId);
|
const threadIdRef = useRef(threadId);
|
||||||
|
|
||||||
@ -127,6 +136,102 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
}
|
}
|
||||||
}, [artifactsOpen, setArtifactsOpen, sidecarOpen]);
|
}, [artifactsOpen, setArtifactsOpen, sidecarOpen]);
|
||||||
|
|
||||||
|
const rightPanelContent = useMemo(() => {
|
||||||
|
if (renderedRightPanel === "sidecar") {
|
||||||
|
return <SidecarPanel />;
|
||||||
|
}
|
||||||
|
if (renderedRightPanel === "artifacts" && selectedArtifact) {
|
||||||
|
return (
|
||||||
|
<ArtifactFileDetail
|
||||||
|
className="size-full"
|
||||||
|
filepath={selectedArtifact}
|
||||||
|
threadId={threadId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (renderedRightPanel === "artifacts") {
|
||||||
|
return (
|
||||||
|
<div className="relative flex size-full justify-center">
|
||||||
|
<div className="absolute top-1 right-1 z-30">
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setArtifactsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{artifacts.length === 0 ? (
|
||||||
|
<ConversationEmptyState
|
||||||
|
icon={<FilesIcon />}
|
||||||
|
title="No artifact selected"
|
||||||
|
description="Select an artifact to view its details"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||||
|
<header className="shrink-0">
|
||||||
|
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||||
|
</header>
|
||||||
|
<main className="min-h-0 grow">
|
||||||
|
<ArtifactFileList
|
||||||
|
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||||
|
files={artifacts}
|
||||||
|
threadId={threadId}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [
|
||||||
|
renderedRightPanel,
|
||||||
|
selectedArtifact,
|
||||||
|
threadId,
|
||||||
|
artifacts,
|
||||||
|
setArtifactsOpen,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="relative size-full min-w-0">{children}</div>
|
||||||
|
<Sheet
|
||||||
|
open={rightPanelOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sidecarOpen) {
|
||||||
|
sidecar?.close();
|
||||||
|
}
|
||||||
|
if (artifactsOpen) {
|
||||||
|
setArtifactsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SheetContent
|
||||||
|
className="w-[calc(100vw-1rem)] max-w-none gap-0 p-0 sm:max-w-md [&>button]:hidden"
|
||||||
|
side="right"
|
||||||
|
>
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>
|
||||||
|
{renderedRightPanel === "sidecar" ? "Sidecar" : "Artifacts"}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Browse the side panel for this conversation.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="min-h-0 flex-1 p-3 pt-10">{rightPanelContent}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`${resizableIdBase}-panels`}
|
id={`${resizableIdBase}-panels`}
|
||||||
@ -163,49 +268,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
rightPanelOpen ? "opacity-100" : "opacity-0",
|
rightPanelOpen ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderedRightPanel === "sidecar" ? (
|
{rightPanelContent}
|
||||||
<SidecarPanel />
|
|
||||||
) : renderedRightPanel === "artifacts" && selectedArtifact ? (
|
|
||||||
<ArtifactFileDetail
|
|
||||||
className="size-full"
|
|
||||||
filepath={selectedArtifact}
|
|
||||||
threadId={threadId}
|
|
||||||
/>
|
|
||||||
) : renderedRightPanel === "artifacts" ? (
|
|
||||||
<div className="relative flex size-full justify-center">
|
|
||||||
<div className="absolute top-1 right-1 z-30">
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setArtifactsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{artifacts.length === 0 ? (
|
|
||||||
<ConversationEmptyState
|
|
||||||
icon={<FilesIcon />}
|
|
||||||
title="No artifact selected"
|
|
||||||
description="Select an artifact to view its details"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
|
||||||
<header className="shrink-0">
|
|
||||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
|
||||||
</header>
|
|
||||||
<main className="min-h-0 grow">
|
|
||||||
<ArtifactFileList
|
|
||||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
|
||||||
files={artifacts}
|
|
||||||
threadId={threadId}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -58,11 +58,12 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
|
|||||||
<Tooltip content={t.common.export}>
|
<Tooltip content={t.common.export}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
aria-label={t.common.export}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Download />
|
<Download />
|
||||||
{t.common.export}
|
<span className="hidden sm:inline">{t.common.export}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { CalendarClock } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -8,9 +9,11 @@ export function ThreadScheduledTasksLink({ threadId }: { threadId: string }) {
|
|||||||
return (
|
return (
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Link
|
<Link
|
||||||
|
aria-label={t.sidebar.scheduledTasks}
|
||||||
href={`/workspace/scheduled-tasks?thread_id=${encodeURIComponent(threadId)}`}
|
href={`/workspace/scheduled-tasks?thread_id=${encodeURIComponent(threadId)}`}
|
||||||
>
|
>
|
||||||
{t.sidebar.scheduledTasks}
|
<CalendarClock />
|
||||||
|
<span className="hidden sm:inline">{t.sidebar.scheduledTasks}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -51,6 +51,7 @@ export const enUS: Translations = {
|
|||||||
exportAsJSON: "Export as JSON",
|
exportAsJSON: "Export as JSON",
|
||||||
exportSuccess: "Conversation exported",
|
exportSuccess: "Conversation exported",
|
||||||
regenerate: "Regenerate",
|
regenerate: "Regenerate",
|
||||||
|
showArtifacts: "Show artifacts of this conversation",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Home
|
// Home
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export interface Translations {
|
|||||||
exportAsJSON: string;
|
exportAsJSON: string;
|
||||||
exportSuccess: string;
|
exportSuccess: string;
|
||||||
regenerate: string;
|
regenerate: string;
|
||||||
|
showArtifacts: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
home: {
|
home: {
|
||||||
|
|||||||
@ -51,6 +51,7 @@ export const zhCN: Translations = {
|
|||||||
exportAsJSON: "导出为 JSON",
|
exportAsJSON: "导出为 JSON",
|
||||||
exportSuccess: "对话已导出",
|
exportSuccess: "对话已导出",
|
||||||
regenerate: "重新生成",
|
regenerate: "重新生成",
|
||||||
|
showArtifacts: "查看此对话的文件",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Home
|
// Home
|
||||||
|
|||||||
@ -1,21 +1,22 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768;
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
|
||||||
|
|
||||||
|
function subscribe(callback: () => void) {
|
||||||
|
const mql = window.matchMedia(MOBILE_QUERY);
|
||||||
|
mql.addEventListener("change", callback);
|
||||||
|
return () => mql.removeEventListener("change", callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot() {
|
||||||
|
return window.matchMedia(MOBILE_QUERY).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerSnapshot() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function useIsMobile() {
|
export function useIsMobile() {
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
||||||
const onChange = () => {
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
||||||
};
|
|
||||||
mql.addEventListener("change", onChange);
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
||||||
return () => mql.removeEventListener("change", onChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return !!isMobile;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -241,7 +241,7 @@
|
|||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0.0098 87.47);
|
--border: oklch(0.922 0.0098 87.47);
|
||||||
--input: oklch(0.88 0.0098 87.47);
|
--input: oklch(0.88 0.0098 87.47);
|
||||||
--ring: transparent;
|
--ring: oklch(0.58 0.18 255);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
@ -275,7 +275,7 @@
|
|||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: transparent;
|
--ring: oklch(0.74 0.16 255);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
|||||||
@ -6,10 +6,11 @@ test.describe("Landing page", () => {
|
|||||||
test("renders the header and hero section", async ({ page }) => {
|
test("renders the header and hero section", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
// Header brand name
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("header h1", { hasText: "DeerFlow" }),
|
page.locator("header").first().getByText("DeerFlow", { exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
await expect(page.locator("h1")).toHaveCount(1);
|
||||||
|
await expect(page.locator("h1")).toContainText("DeerFlow");
|
||||||
|
|
||||||
// "Get Started" call-to-action button in hero
|
// "Get Started" call-to-action button in hero
|
||||||
await expect(
|
await expect(
|
||||||
@ -17,6 +18,18 @@ test.describe("Landing page", () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const width of [320, 375, 390]) {
|
||||||
|
test(`does not overflow at ${width}px width`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 812 });
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
|
||||||
|
.toBeLessThanOrEqual(width);
|
||||||
|
await expect(page.locator("main").first()).toBeInViewport();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test("Get Started link navigates to workspace", async ({ page }) => {
|
test("Get Started link navigates to workspace", async ({ page }) => {
|
||||||
mockLangGraphAPI(page);
|
mockLangGraphAPI(page);
|
||||||
|
|
||||||
|
|||||||
78
frontend/tests/e2e/ui-polish-mobile.spec.ts
Normal file
78
frontend/tests/e2e/ui-polish-mobile.spec.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { MOCK_THREAD_ID, mockLangGraphAPI } from "./utils/mock-api";
|
||||||
|
|
||||||
|
test.describe("UI polish mobile regressions", () => {
|
||||||
|
test("workspace exposes mobile sidebar navigation from the chat header", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
mockLangGraphAPI(page);
|
||||||
|
|
||||||
|
await page.goto("/workspace/chats/new");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /toggle sidebar/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("link", { name: /new chat/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: /agents/i })).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
|
||||||
|
.toBeLessThanOrEqual(375);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile artifacts open in a drawer without horizontal overflow", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
mockLangGraphAPI(page, {
|
||||||
|
threads: [
|
||||||
|
{
|
||||||
|
thread_id: MOCK_THREAD_ID,
|
||||||
|
title: "Thread with artifact",
|
||||||
|
artifacts: ["reports/mobile-summary.md"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||||
|
await page.getByTestId("artifact-trigger").click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: /artifacts/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("mobile-summary.md")).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
|
||||||
|
.toBeLessThanOrEqual(375);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("global focus ring tokens are visible in light and dark themes", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
mockLangGraphAPI(page);
|
||||||
|
await page.goto("/workspace/chats/new");
|
||||||
|
|
||||||
|
const readRing = () =>
|
||||||
|
page.evaluate(() =>
|
||||||
|
getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--ring")
|
||||||
|
.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.documentElement.classList.remove("dark"),
|
||||||
|
);
|
||||||
|
const lightRing = await readRing();
|
||||||
|
expect(lightRing).not.toBe("transparent");
|
||||||
|
expect(lightRing).not.toBe("");
|
||||||
|
|
||||||
|
await page.evaluate(() => document.documentElement.classList.add("dark"));
|
||||||
|
const darkRing = await readRing();
|
||||||
|
expect(darkRing).not.toBe("transparent");
|
||||||
|
expect(darkRing).not.toBe("");
|
||||||
|
|
||||||
|
// The two themes must resolve to different ring tokens, otherwise the test
|
||||||
|
// would pass trivially if <html> were stuck in one mode.
|
||||||
|
expect(darkRing).not.toBe(lightRing);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user