From 480a3757ed0e9e347c15f242d668ffe2f3b57b51 Mon Sep 17 00:00:00 2001
From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com>
Date: Tue, 4 Aug 2026 17:49:14 -0700
Subject: [PATCH] fix(frontend): add public case study routes (#4635)
---
README.md | 1 +
frontend/AGENTS.md | 2 +-
frontend/README.md | 1 +
.../src/app/showcase/[thread_id]/layout.tsx | 34 +++++++++++++++++++
.../src/app/showcase/[thread_id]/page.tsx | 22 ++++++++++++
.../workspace/chats/[thread_id]/layout.tsx | 3 +-
.../app/workspace/chats/[thread_id]/page.tsx | 6 ++--
.../landing/sections/case-study-section.tsx | 4 +--
.../workspace/chats/chat-providers.tsx} | 0
.../workspace/chats/use-thread-chat.ts | 4 ++-
frontend/src/core/threads/static-demo.ts | 10 ++++++
frontend/tests/e2e/thread-history.spec.ts | 8 ++++-
.../unit/core/threads/static-demo.test.ts | 15 ++++++++
13 files changed, 100 insertions(+), 10 deletions(-)
create mode 100644 frontend/src/app/showcase/[thread_id]/layout.tsx
create mode 100644 frontend/src/app/showcase/[thread_id]/page.tsx
rename frontend/src/{app/workspace/chats/[thread_id]/providers.tsx => components/workspace/chats/chat-providers.tsx} (100%)
diff --git a/README.md b/README.md
index d13e2ee50..958cd4e53 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
## Official Website
Learn more and see **real demos** on our [**official website**](https://deerflow.tech).
+The landing-page case studies open as allowlisted, read-only showcases without requiring a sign-in.
## Sister Projects
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 5a168292f..c71ad7430 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -48,7 +48,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat
### Source Layout (`src/`)
-- **`app/`** — Next.js App Router. Routes include `/` (landing), `/workspace/chats/[thread_id]` (chat), `/workspace/agents/[agent_name]` and `/workspace/agents/new` (custom agents), `/blog/…`, the `(auth)/{login,setup,auth/callback}` flow, `/[lang]/docs/…`, and `/api/…` route handlers (e.g. `/api/memory`).
+- **`app/`** — Next.js App Router. Routes include `/` (landing), `/showcase/[thread_id]` (allowlisted public read-only demos), `/workspace/chats/[thread_id]` (authenticated chat), `/workspace/agents/[agent_name]` and `/workspace/agents/new` (custom agents), `/blog/…`, the `(auth)/{login,setup,auth/callback}` flow, `/[lang]/docs/…`, and `/api/…` route handlers (e.g. `/api/memory`).
- **`components/`** — React components:
- `ui/` — Shadcn UI primitives (auto-generated, ESLint-ignored)
- `ai-elements/` — Vercel AI SDK elements (auto-generated, ESLint-ignored)
diff --git a/frontend/README.md b/frontend/README.md
index 76c450232..7660d8e34 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -97,6 +97,7 @@ tests/
src/
├── app/ # Next.js App Router pages
│ ├── api/ # API routes
+│ ├── showcase/ # Allowlisted public read-only demos
│ ├── workspace/ # Main workspace pages
│ └── mock/ # Mock/demo pages
├── components/ # React components
diff --git a/frontend/src/app/showcase/[thread_id]/layout.tsx b/frontend/src/app/showcase/[thread_id]/layout.tsx
new file mode 100644
index 000000000..7bf94bfb2
--- /dev/null
+++ b/frontend/src/app/showcase/[thread_id]/layout.tsx
@@ -0,0 +1,34 @@
+import "katex/dist/katex.min.css";
+import "streamdown/styles.css";
+
+import { Toaster } from "sonner";
+
+import { QueryClientProvider } from "@/components/query-client-provider";
+import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
+import { ChatProviders } from "@/components/workspace/chats/chat-providers";
+import { AuthProvider } from "@/core/auth/AuthProvider";
+import { I18nProvider } from "@/core/i18n/context";
+import { detectLocaleServer } from "@/core/i18n/server";
+
+export default async function PublicShowcaseLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const locale = await detectLocaleServer();
+
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/showcase/[thread_id]/page.tsx b/frontend/src/app/showcase/[thread_id]/page.tsx
new file mode 100644
index 000000000..2a76fa06b
--- /dev/null
+++ b/frontend/src/app/showcase/[thread_id]/page.tsx
@@ -0,0 +1,22 @@
+import { notFound } from "next/navigation";
+
+import ChatPage from "@/app/workspace/chats/[thread_id]/page";
+import { DEMO_THREAD_IDS, isDemoThreadId } from "@/core/threads/static-demo";
+
+export const dynamicParams = false;
+
+export function generateStaticParams() {
+ return DEMO_THREAD_IDS.map((thread_id) => ({ thread_id }));
+}
+
+export default async function PublicShowcasePage({
+ params,
+}: {
+ params: Promise<{ thread_id: string }>;
+}) {
+ const { thread_id: threadId } = await params;
+ if (!isDemoThreadId(threadId)) {
+ notFound();
+ }
+ return ;
+}
diff --git a/frontend/src/app/workspace/chats/[thread_id]/layout.tsx b/frontend/src/app/workspace/chats/[thread_id]/layout.tsx
index eeee68347..f4dd28133 100644
--- a/frontend/src/app/workspace/chats/[thread_id]/layout.tsx
+++ b/frontend/src/app/workspace/chats/[thread_id]/layout.tsx
@@ -1,8 +1,7 @@
+import { ChatProviders } from "@/components/workspace/chats/chat-providers";
import { isStaticWebsiteOnly } from "@/core/static-mode";
import { DEMO_THREAD_IDS } from "@/core/threads/static-demo";
-import { ChatProviders } from "./providers";
-
export function generateStaticParams() {
if (!isStaticWebsiteOnly()) {
return [];
diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx
index 7175db866..4c79aa2e6 100644
--- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx
+++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx
@@ -254,7 +254,7 @@ export default function ChatPage() {
? localSettings.tokenUsage.inlineMode
: "off";
const hasTodos = (thread.values.todos?.length ?? 0) > 0;
- const browserEnabled = !isNewThread && browserControlEnabled;
+ const browserEnabled = !isNewThread && !isMock && browserControlEnabled;
const { activeGoal, hasGoal, setLocalGoal } = useActiveGoal(
threadId,
thread.values.goal,
@@ -285,12 +285,12 @@ export default function ChatPage() {
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
-
+ {!isMock && }
- {!isNewThread && (
+ {!isNewThread && !isMock && (
)}
{tokenUsageEnabled ? (
diff --git a/frontend/src/components/landing/sections/case-study-section.tsx b/frontend/src/components/landing/sections/case-study-section.tsx
index 87b0522d2..385d9d1b3 100644
--- a/frontend/src/components/landing/sections/case-study-section.tsx
+++ b/frontend/src/components/landing/sections/case-study-section.tsx
@@ -2,7 +2,7 @@ import Image from "next/image";
import Link from "next/link";
import { Card } from "@/components/ui/card";
-import { pathOfThread } from "@/core/threads/utils";
+import { pathOfPublicDemoThread } from "@/core/threads/static-demo";
import { cn } from "@/lib/utils";
import { Section } from "../section";
@@ -56,7 +56,7 @@ export function CaseStudySection({ className }: { className?: string }) {
{caseStudies.map((caseStudy) => (
diff --git a/frontend/src/app/workspace/chats/[thread_id]/providers.tsx b/frontend/src/components/workspace/chats/chat-providers.tsx
similarity index 100%
rename from frontend/src/app/workspace/chats/[thread_id]/providers.tsx
rename to frontend/src/components/workspace/chats/chat-providers.tsx
diff --git a/frontend/src/components/workspace/chats/use-thread-chat.ts b/frontend/src/components/workspace/chats/use-thread-chat.ts
index fded8dfbe..4c2cddc50 100644
--- a/frontend/src/components/workspace/chats/use-thread-chat.ts
+++ b/frontend/src/components/workspace/chats/use-thread-chat.ts
@@ -119,7 +119,9 @@ export function useThreadChat() {
setIsNewThreadState(nextIsNewThread);
}, []);
- const isMock = searchParams.get("mock") === "true";
+ const isMock =
+ actualPathname.startsWith("/showcase/") ||
+ searchParams.get("mock") === "true";
return {
threadId: isNewPath ? (newThreadIdRef.current ?? threadId) : threadId,
setThreadId,
diff --git a/frontend/src/core/threads/static-demo.ts b/frontend/src/core/threads/static-demo.ts
index 8671a7f46..e0894af73 100644
--- a/frontend/src/core/threads/static-demo.ts
+++ b/frontend/src/core/threads/static-demo.ts
@@ -125,6 +125,16 @@ export function resolveStaticDemoArtifact(
return `/demo/threads/${threadId}/${artifactPath}`;
}
+const DEMO_THREAD_ID_SET = new Set(DEMO_THREAD_IDS);
+
+export function isDemoThreadId(threadId: string): boolean {
+ return DEMO_THREAD_ID_SET.has(threadId);
+}
+
+export function pathOfPublicDemoThread(threadId: string): string {
+ return `/showcase/${encodeURIComponent(threadId)}`;
+}
+
export type ThreadSearchParams = NonNullable<
Parameters[0]
>;
diff --git a/frontend/tests/e2e/thread-history.spec.ts b/frontend/tests/e2e/thread-history.spec.ts
index 1c622e10c..5076093fc 100644
--- a/frontend/tests/e2e/thread-history.spec.ts
+++ b/frontend/tests/e2e/thread-history.spec.ts
@@ -728,7 +728,7 @@ test.describe("Thread history", () => {
},
);
- await page.goto(`/workspace/chats/${DEMO_THREAD_ID}?mock=true`);
+ await page.goto(`/showcase/${DEMO_THREAD_ID}`);
await expect(
page.getByText("What might be the trends and opportunities in 2026?"),
@@ -739,6 +739,12 @@ test.describe("Thread history", () => {
expect(backendRunHistoryUrls).toEqual([]);
});
+ test("public showcase rejects unknown thread IDs", async ({ page }) => {
+ const response = await page.goto("/showcase/not-a-bundled-demo");
+
+ expect(response?.status()).toBe(404);
+ });
+
test("chats list page shows all threads", async ({ page }) => {
mockLangGraphAPI(page, { threads: THREADS });
diff --git a/frontend/tests/unit/core/threads/static-demo.test.ts b/frontend/tests/unit/core/threads/static-demo.test.ts
index 7848cf7d7..0d0bf2225 100644
--- a/frontend/tests/unit/core/threads/static-demo.test.ts
+++ b/frontend/tests/unit/core/threads/static-demo.test.ts
@@ -5,6 +5,8 @@ import { describe, expect, it } from "@rstest/core";
import {
DEMO_THREAD_IDS,
+ isDemoThreadId,
+ pathOfPublicDemoThread,
resolveStaticDemoArtifact,
STATIC_DEMO_ARTIFACTS,
} from "@/core/threads/static-demo";
@@ -65,3 +67,16 @@ describe("resolveStaticDemoArtifact", () => {
}
});
});
+
+describe("public demo threads", () => {
+ it("recognizes only bundled demo thread IDs", () => {
+ expect(isDemoThreadId(DEMO_THREAD_IDS[0])).toBe(true);
+ expect(isDemoThreadId("not-a-demo-thread")).toBe(false);
+ });
+
+ it("builds an encoded public route in mock mode", () => {
+ expect(pathOfPublicDemoThread("thread/id?")).toBe(
+ "/showcase/thread%2Fid%3F",
+ );
+ });
+});