From 7a985f441cd18a7a29220388287eddf6b3eadfae Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Mon, 6 Jul 2026 07:15:35 +0800 Subject: [PATCH] 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
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
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 --- frontend/src/app/page.tsx | 2 +- frontend/src/components/landing/header.tsx | 19 ++- frontend/src/components/landing/hero.tsx | 100 ++++++++---- .../src/components/landing/mobile-nav.tsx | 49 ++++++ frontend/src/components/landing/section.tsx | 12 +- .../landing/sections/case-study-section.tsx | 2 +- frontend/src/components/ui/word-rotate.tsx | 53 ------- .../workspace/artifacts/artifact-trigger.tsx | 6 +- .../components/workspace/chats/chat-box.tsx | 149 +++++++++++++----- .../components/workspace/export-trigger.tsx | 3 +- .../workspace/thread-scheduled-tasks-link.tsx | 5 +- frontend/src/core/i18n/locales/en-US.ts | 1 + frontend/src/core/i18n/locales/types.ts | 1 + frontend/src/core/i18n/locales/zh-CN.ts | 1 + frontend/src/hooks/use-mobile.ts | 31 ++-- frontend/src/styles/globals.css | 4 +- frontend/tests/e2e/landing.spec.ts | 17 +- frontend/tests/e2e/ui-polish-mobile.spec.ts | 78 +++++++++ 18 files changed, 373 insertions(+), 160 deletions(-) create mode 100644 frontend/src/components/landing/mobile-nav.tsx delete mode 100644 frontend/src/components/ui/word-rotate.tsx create mode 100644 frontend/tests/e2e/ui-polish-mobile.spec.ts diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index e5d3187b7..71f8de179 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -9,7 +9,7 @@ import { WhatsNewSection } from "@/components/landing/sections/whats-new-section export default function LandingPage() { return ( -
+
diff --git a/frontend/src/components/landing/header.tsx b/frontend/src/components/landing/header.tsx index de7540a02..6adb8b15d 100644 --- a/frontend/src/components/landing/header.tsx +++ b/frontend/src/components/landing/header.tsx @@ -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 (
-
+ -
+
); diff --git a/frontend/src/components/landing/hero.tsx b/frontend/src/components/landing/hero.tsx index 503c5b88c..34aa91762 100644 --- a/frontend/src/components/landing/hero.tsx +++ b/frontend/src/components/landing/hero.tsx @@ -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 (
-
-

- {" "} -
with DeerFlow
+
+

+ DeerFlow

+
+ + SuperAgent +
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && ( )} -

+

An open-source SuperAgent harness that researches, codes, and creates. - With -
- the help of sandboxes, memories, tools, skills and subagents, it - handles -
- 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.

- @@ -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 ( +
+ + + + {words[index]} + + + +
+ ); +} + function BytePlusIcon(props: React.SVGProps) { return ( + + + + + + DeerFlow + + + + + ); +} diff --git a/frontend/src/components/landing/section.tsx b/frontend/src/components/landing/section.tsx index 971466974..01b6fff7d 100644 --- a/frontend/src/components/landing/section.tsx +++ b/frontend/src/components/landing/section.tsx @@ -12,18 +12,20 @@ export function Section({ children: React.ReactNode; }) { return ( -
-
-
+
+
+
{title}
{subtitle && ( -
+
{subtitle}
)}
-
{children}
+
{children}
); } diff --git a/frontend/src/components/landing/sections/case-study-section.tsx b/frontend/src/components/landing/sections/case-study-section.tsx index aeb9163e8..a08e6a2db 100644 --- a/frontend/src/components/landing/sections/case-study-section.tsx +++ b/frontend/src/components/landing/sections/case-study-section.tsx @@ -51,7 +51,7 @@ export function CaseStudySection({ className }: { className?: string }) { title="Case Studies" subtitle="See how DeerFlow is used in the wild" > -
+
{caseStudies.map((caseStudy) => ( { - const interval = setInterval(() => { - setIndex((prevIndex) => (prevIndex + 1) % words.length); - }, duration); - - // Clean up interval on unmount - return () => clearInterval(interval); - }, [words, duration]); - - return ( -
- - - - {words[index]} - - - -
- ); -} diff --git a/frontend/src/components/workspace/artifacts/artifact-trigger.tsx b/frontend/src/components/workspace/artifacts/artifact-trigger.tsx index 40d353559..3ce35e7f2 100644 --- a/frontend/src/components/workspace/artifacts/artifact-trigger.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-trigger.tsx @@ -17,17 +17,19 @@ export const ArtifactTrigger = () => { return null; } return ( - + ); diff --git a/frontend/src/components/workspace/chats/chat-box.tsx b/frontend/src/components/workspace/chats/chat-box.tsx index 6fa8c97bd..e712d32f2 100644 --- a/frontend/src/components/workspace/chats/chat-box.tsx +++ b/frontend/src/components/workspace/chats/chat-box.tsx @@ -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 ; + } + if (renderedRightPanel === "artifacts" && selectedArtifact) { + return ( + + ); + } + if (renderedRightPanel === "artifacts") { + return ( +
+
+ +
+ {artifacts.length === 0 ? ( + } + title="No artifact selected" + description="Select an artifact to view its details" + /> + ) : ( +
+
+

Artifacts

+
+
+ +
+
+ )} +
+ ); + } + return null; + }, [ + renderedRightPanel, + selectedArtifact, + threadId, + artifacts, + setArtifactsOpen, + ]); + + if (isMobile) { + return ( + <> +
{children}
+ { + if (open) { + return; + } + if (sidecarOpen) { + sidecar?.close(); + } + if (artifactsOpen) { + setArtifactsOpen(false); + } + }} + > + + + + {renderedRightPanel === "sidecar" ? "Sidecar" : "Artifacts"} + + + Browse the side panel for this conversation. + + +
{rightPanelContent}
+
+
+ + ); + } + return (
= ({ rightPanelOpen ? "opacity-100" : "opacity-0", )} > - {renderedRightPanel === "sidecar" ? ( - - ) : renderedRightPanel === "artifacts" && selectedArtifact ? ( - - ) : renderedRightPanel === "artifacts" ? ( -
-
- -
- {artifacts.length === 0 ? ( - } - title="No artifact selected" - description="Select an artifact to view its details" - /> - ) : ( -
-
-

Artifacts

-
-
- -
-
- )} -
- ) : null} + {rightPanelContent}
diff --git a/frontend/src/components/workspace/export-trigger.tsx b/frontend/src/components/workspace/export-trigger.tsx index b75d4e451..a42784a54 100644 --- a/frontend/src/components/workspace/export-trigger.tsx +++ b/frontend/src/components/workspace/export-trigger.tsx @@ -58,11 +58,12 @@ export function ExportTrigger({ threadId }: { threadId: string }) { diff --git a/frontend/src/components/workspace/thread-scheduled-tasks-link.tsx b/frontend/src/components/workspace/thread-scheduled-tasks-link.tsx index 45c943a61..3df4b7857 100644 --- a/frontend/src/components/workspace/thread-scheduled-tasks-link.tsx +++ b/frontend/src/components/workspace/thread-scheduled-tasks-link.tsx @@ -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 ( ); diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index a1a410dc8..73bbab98b 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -51,6 +51,7 @@ export const enUS: Translations = { exportAsJSON: "Export as JSON", exportSuccess: "Conversation exported", regenerate: "Regenerate", + showArtifacts: "Show artifacts of this conversation", }, // Home diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index c07755235..d1c075784 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -40,6 +40,7 @@ export interface Translations { exportAsJSON: string; exportSuccess: string; regenerate: string; + showArtifacts: string; }; home: { diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index d170b2676..0a8fc6b42 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -51,6 +51,7 @@ export const zhCN: Translations = { exportAsJSON: "导出为 JSON", exportSuccess: "对话已导出", regenerate: "重新生成", + showArtifacts: "查看此对话的文件", }, // Home diff --git a/frontend/src/hooks/use-mobile.ts b/frontend/src/hooks/use-mobile.ts index a93d58393..8de39b7f2 100644 --- a/frontend/src/hooks/use-mobile.ts +++ b/frontend/src/hooks/use-mobile.ts @@ -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( - 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); } diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 22339b229..bf1f73170 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -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); diff --git a/frontend/tests/e2e/landing.spec.ts b/frontend/tests/e2e/landing.spec.ts index b9d59f918..c61903516 100644 --- a/frontend/tests/e2e/landing.spec.ts +++ b/frontend/tests/e2e/landing.spec.ts @@ -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); diff --git a/frontend/tests/e2e/ui-polish-mobile.spec.ts b/frontend/tests/e2e/ui-polish-mobile.spec.ts new file mode 100644 index 000000000..37d726313 --- /dev/null +++ b/frontend/tests/e2e/ui-polish-mobile.spec.ts @@ -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 were stuck in one mode. + expect(darkRing).not.toBe(lightRing); + }); +});