);
}
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 (
-
+
{
sidecar?.close();
setArtifactsOpen(true);
}}
>
- {t.common.artifacts}
+ {t.common.artifacts}
);
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 (
+
+
+ {
+ setArtifactsOpen(false);
+ }}
+ >
+
+
+
+ {artifacts.length === 0 ? (
+
}
+ title="No artifact selected"
+ description="Select an artifact to view its details"
+ />
+ ) : (
+
+ )}
+
+ );
+ }
+ 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" ? (
-
-
- {
- setArtifactsOpen(false);
- }}
- >
-
-
-
- {artifacts.length === 0 ? (
-
}
- title="No artifact selected"
- description="Select an artifact to view its details"
- />
- ) : (
-
- )}
-
- ) : 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 }) {
- {t.common.export}
+ {t.common.export}
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 (
- {t.sidebar.scheduledTasks}
+
+ {t.sidebar.scheduledTasks}
);
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);
+ });
+});