mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 10:15:47 +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:
co-authored by
Willem Jiang
parent
f0f9dd6656
commit
7a985f441c
@@ -9,7 +9,7 @@ import { WhatsNewSection } from "@/components/landing/sections/whats-new-section
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-[#0a0a0a]">
|
||||
<div className="min-h-screen w-full overflow-x-clip bg-[#0a0a0a]">
|
||||
<Header />
|
||||
<main className="flex w-full flex-col">
|
||||
<Hero />
|
||||
|
||||
@@ -8,6 +8,8 @@ import { getI18n } from "@/core/i18n/server";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { MobileNav } from "./mobile-nav";
|
||||
|
||||
export type HeaderProps = {
|
||||
className?: string;
|
||||
homeURL?: string;
|
||||
@@ -21,20 +23,21 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
|
||||
return (
|
||||
<header
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex min-w-0 items-center gap-6">
|
||||
<a
|
||||
href={homeURL ?? "https://github.com/bytedance/deer-flow"}
|
||||
target={isExternalHome ? "_blank" : "_self"}
|
||||
rel={isExternalHome ? "noopener noreferrer" : undefined}
|
||||
className="font-serif text-xl whitespace-nowrap"
|
||||
>
|
||||
<h1 className="font-serif text-xl">DeerFlow</h1>
|
||||
DeerFlow
|
||||
</a>
|
||||
</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
|
||||
href={`/${lang}/docs`}
|
||||
className="text-secondary-foreground hover:text-foreground transition-colors"
|
||||
@@ -68,12 +71,18 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<GitHubLogoIcon className="size-4" />
|
||||
Star on GitHub
|
||||
<span className="hidden sm:inline">Star on GitHub</span>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
env.GITHUB_OAUTH_TOKEN && <StarCounter />}
|
||||
</a>
|
||||
</Button>
|
||||
</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" />
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { AuroraText } from "@/components/ui/aurora-text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
||||
import Galaxy from "@/components/ui/galaxy";
|
||||
import { WordRotate } from "@/components/ui/word-rotate";
|
||||
import { env } from "@/env";
|
||||
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 }) {
|
||||
return (
|
||||
<div
|
||||
@@ -36,27 +54,14 @@ export function Hero({ className }: { className?: string }) {
|
||||
maxOpacity={0.3}
|
||||
flickerChance={0.25}
|
||||
/>
|
||||
<div className="container-md relative z-10 mx-auto flex h-screen flex-col items-center justify-center">
|
||||
<h1 className="flex items-center gap-2 text-4xl font-bold md:text-6xl">
|
||||
<WordRotate
|
||||
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>
|
||||
<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="text-center text-5xl leading-tight font-bold break-words md:text-6xl">
|
||||
DeerFlow
|
||||
</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 && (
|
||||
<a
|
||||
href="https://byteplus.com"
|
||||
@@ -69,17 +74,13 @@ export function Hero({ className }: { className?: string }) {
|
||||
</div>
|
||||
</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.
|
||||
With
|
||||
<br />
|
||||
the help of sandboxes, memories, tools, skills and subagents, it
|
||||
handles
|
||||
<br />
|
||||
different levels of tasks that could take minutes to hours.
|
||||
With the help of sandboxes, memories, tools, skills and subagents, it
|
||||
handles different levels of tasks that could take minutes to hours.
|
||||
</p>
|
||||
<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>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</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>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -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;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("mx-auto flex flex-col py-16", className)}>
|
||||
<header className="flex flex-col items-center justify-between">
|
||||
<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">
|
||||
<section
|
||||
className={cn("mx-auto flex w-full min-w-0 flex-col py-16", className)}
|
||||
>
|
||||
<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}
|
||||
</div>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<main className="mt-4">{children}</main>
|
||||
<main className="mt-4 w-full min-w-0 px-4">{children}</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function CaseStudySection({ className }: { className?: string }) {
|
||||
title="Case Studies"
|
||||
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) => (
|
||||
<Link
|
||||
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 (
|
||||
<Tooltip content="Show artifacts of this conversation">
|
||||
<Tooltip content={t.common.showArtifacts}>
|
||||
<Button
|
||||
aria-label={t.common.showArtifacts}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
data-testid="artifact-trigger"
|
||||
onClick={() => {
|
||||
sidecar?.close();
|
||||
setArtifactsOpen(true);
|
||||
}}
|
||||
>
|
||||
<FilesIcon />
|
||||
{t.common.artifacts}
|
||||
<span className="hidden sm:inline">{t.common.artifacts}</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,15 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { env } from "@/env";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
@@ -24,6 +32,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
threadId,
|
||||
}) => {
|
||||
const { thread } = useThread();
|
||||
const isMobile = useIsMobile();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
|
||||
@@ -127,6 +136,102 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
}
|
||||
}, [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 (
|
||||
<div
|
||||
id={`${resizableIdBase}-panels`}
|
||||
@@ -163,49 +268,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
rightPanelOpen ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{renderedRightPanel === "sidecar" ? (
|
||||
<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}
|
||||
{rightPanelContent}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -58,11 +58,12 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
|
||||
<Tooltip content={t.common.export}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={t.common.export}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
>
|
||||
<Download />
|
||||
{t.common.export}
|
||||
<span className="hidden sm:inline">{t.common.export}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -8,9 +9,11 @@ export function ThreadScheduledTasksLink({ threadId }: { threadId: string }) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
aria-label={t.sidebar.scheduledTasks}
|
||||
href={`/workspace/scheduled-tasks?thread_id=${encodeURIComponent(threadId)}`}
|
||||
>
|
||||
{t.sidebar.scheduledTasks}
|
||||
<CalendarClock />
|
||||
<span className="hidden sm:inline">{t.sidebar.scheduledTasks}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -51,6 +51,7 @@ export const enUS: Translations = {
|
||||
exportAsJSON: "Export as JSON",
|
||||
exportSuccess: "Conversation exported",
|
||||
regenerate: "Regenerate",
|
||||
showArtifacts: "Show artifacts of this conversation",
|
||||
},
|
||||
|
||||
// Home
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface Translations {
|
||||
exportAsJSON: string;
|
||||
exportSuccess: string;
|
||||
regenerate: string;
|
||||
showArtifacts: string;
|
||||
};
|
||||
|
||||
home: {
|
||||
|
||||
@@ -51,6 +51,7 @@ export const zhCN: Translations = {
|
||||
exportAsJSON: "导出为 JSON",
|
||||
exportSuccess: "对话已导出",
|
||||
regenerate: "重新生成",
|
||||
showArtifacts: "查看此对话的文件",
|
||||
},
|
||||
|
||||
// Home
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
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() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
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;
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 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-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
@@ -275,7 +275,7 @@
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--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-2: oklch(0.696 0.17 162.48);
|
||||
--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 }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Header brand name
|
||||
await expect(
|
||||
page.locator("header h1", { hasText: "DeerFlow" }),
|
||||
page.locator("header").first().getByText("DeerFlow", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator("h1")).toHaveCount(1);
|
||||
await expect(page.locator("h1")).toContainText("DeerFlow");
|
||||
|
||||
// "Get Started" call-to-action button in hero
|
||||
await expect(
|
||||
@@ -17,6 +18,18 @@ test.describe("Landing page", () => {
|
||||
).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 }) => {
|
||||
mockLangGraphAPI(page);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user