From e23dd8f88b5cc172e7ed67a0a791bd226108c80b Mon Sep 17 00:00:00 2001 From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:16:58 -0700 Subject: [PATCH] refactor(frontend): share showcase chat page (#4765) --- .../src/app/showcase/[thread_id]/page.tsx | 2 +- .../app/workspace/chats/[thread_id]/page.tsx | 464 +----------------- .../components/workspace/chats/chat-page.tsx | 462 +++++++++++++++++ .../workspace/chats/use-thread-chat.ts | 3 +- frontend/src/core/threads/static-demo.ts | 4 +- 5 files changed, 471 insertions(+), 464 deletions(-) create mode 100644 frontend/src/components/workspace/chats/chat-page.tsx diff --git a/frontend/src/app/showcase/[thread_id]/page.tsx b/frontend/src/app/showcase/[thread_id]/page.tsx index 2a76fa06b..0d46ff296 100644 --- a/frontend/src/app/showcase/[thread_id]/page.tsx +++ b/frontend/src/app/showcase/[thread_id]/page.tsx @@ -1,6 +1,6 @@ import { notFound } from "next/navigation"; -import ChatPage from "@/app/workspace/chats/[thread_id]/page"; +import ChatPage from "@/components/workspace/chats/chat-page"; import { DEMO_THREAD_IDS, isDemoThreadId } from "@/core/threads/static-demo"; export const dynamicParams = false; diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 4c79aa2e6..814a89ef5 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -1,463 +1,5 @@ -"use client"; +import ChatPage from "@/components/workspace/chats/chat-page"; -import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; - -import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; -import { SidebarTrigger } from "@/components/ui/sidebar"; -import { ArtifactTrigger } from "@/components/workspace/artifacts"; -import { BrowserTrigger } from "@/components/workspace/browser-view"; -import { - ChatBox, - useSpecificChatMode, - useThreadChat, -} from "@/components/workspace/chats"; -import { ContextUsageBadge } from "@/components/workspace/context-usage-badge"; -import { ExportTrigger } from "@/components/workspace/export-trigger"; -import { GoalStatus } from "@/components/workspace/goal-status"; -import { - InputBox, - type InputBoxSubmitOptions, -} from "@/components/workspace/input-box"; -import { - MessageList, - MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, -} from "@/components/workspace/messages"; -import { ThreadContext } from "@/components/workspace/messages/context"; -import { - SidecarProvider, - SidecarTrigger, -} from "@/components/workspace/sidecar"; -import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link"; -import { ThreadTitle } from "@/components/workspace/thread-title"; -import { TodoList } from "@/components/workspace/todo-list"; -import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; -import { useActiveGoal } from "@/components/workspace/use-active-goal"; -import { Welcome } from "@/components/workspace/welcome"; -import { useBrowserControlEnabled } from "@/core/features"; -import { useI18n } from "@/core/i18n/hooks"; -import { - buildHumanInputResponseText, - hasOpenHumanInputRequest, - type HumanInputRequest, - type HumanInputResponse, -} from "@/core/messages/human-input"; -import { isHiddenFromUIMessage } from "@/core/messages/utils"; -import { useModels } from "@/core/models/hooks"; -import { useNotification } from "@/core/notification/hooks"; -import { useLocalSettings, useThreadSettings } from "@/core/settings"; -import { - useBranchThread, - useThreadMetadata, - useThreadStream, - useThreadTokenUsage, -} from "@/core/threads/hooks"; -import { - selectContextUsage, - threadTokenUsageToTokenUsage, -} from "@/core/threads/token-usage"; -import { textOfMessage } from "@/core/threads/utils"; -import { env } from "@/env"; -import { cn } from "@/lib/utils"; - -export default function ChatPage() { - const { t } = useI18n(); - const router = useRouter(); - const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = - useThreadChat(); - // `isNewThread` tracks whether the backend has the thread yet — gates the - // SDK's history fetch (see issue #2746). `isWelcomeMode` is the visual - // welcome layout (centered input, hero, quick actions); we flip it to false - // the moment the user submits so the UI animates immediately, even though - // `isNewThread` stays true until the backend actually creates the thread. - const [isWelcomeMode, setIsWelcomeMode] = useState(isNewThread); - const [settings, setSettings] = useThreadSettings(threadId); - const [localSettings, setLocalSettings] = useLocalSettings(); - const { enabled: browserControlEnabled } = useBrowserControlEnabled(); - const { tokenUsageEnabled } = useModels(); - const threadTokenUsage = useThreadTokenUsage( - isNewThread || isMock ? undefined : threadId, - { enabled: !isMock }, - ); - const threadMetadata = useThreadMetadata(threadId, { - enabled: !isNewThread && !isMock, - isMock, - }); - const branchThread = useBranchThread(); - const backendTokenUsage = threadTokenUsageToTokenUsage(threadTokenUsage.data); - const contextUsage = selectContextUsage(threadTokenUsage.data); - const mountedRef = useRef(false); - useSpecificChatMode(); - - useEffect(() => { - mountedRef.current = true; - }, []); - - // Keep welcome layout in sync when navigating between threads (sidebar - // clicks, "new chat" button). Submitting in /chats/new flips the layout - // via onSend below — `isNewThread` stays true until onStart, so this effect - // is harmless during the submit transition. - useEffect(() => { - setIsWelcomeMode(isNewThread); - }, [isNewThread]); - - const { showNotification } = useNotification(); - - const { - thread, - pendingUsageMessages, - sendMessage, - regenerateMessage, - editAndRegenerateMessage, - isUploading, - isHistoryLoading, - hasMoreHistory, - loadMoreHistory, - } = useThreadStream({ - threadId: isNewThread ? undefined : threadId, - displayThreadId: threadId, - context: settings.context, - isMock, - // onSend only animates the UI; do NOT flip `isNewThread` here — the - // LangGraph SDK eagerly fetches /history the moment it receives a - // thread id and assumes the thread exists on the backend (issue #2746). - onSend: () => { - setIsWelcomeMode(false); - }, - onStart: (createdThreadId) => { - // ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead. - history.replaceState(null, "", `/workspace/chats/${createdThreadId}`); - setThreadId(createdThreadId); - setIsNewThread(false); - }, - onFinish: (state) => { - if (document.hidden || !document.hasFocus()) { - let body = "Conversation finished"; - const lastMessage = state.messages.at(-1); - if (lastMessage) { - const textContent = textOfMessage(lastMessage); - if (textContent) { - body = - textContent.length > 200 - ? textContent.substring(0, 200) + "..." - : textContent; - } - } - showNotification(state.title, { body }); - } - }, - }); - - const hasThreadMessages = thread.messages.length > 0; - - useEffect(() => { - if ( - !isNewThread && - !isMock && - threadMetadata.data === null && - !threadMetadata.isLoading && - !threadMetadata.isFetching && - !isHistoryLoading && - !hasMoreHistory && - !hasThreadMessages - ) { - router.replace("/workspace/chats/new"); - } - }, [ - hasMoreHistory, - hasThreadMessages, - isHistoryLoading, - isMock, - isNewThread, - router, - threadMetadata.data, - threadMetadata.isFetching, - threadMetadata.isLoading, - ]); - - const handleSubmit = useCallback( - (message: PromptInputMessage, options?: InputBoxSubmitOptions) => { - const sendPromise = sendMessage(threadId, message, undefined, options); - if (message.files.length > 0) { - return sendPromise; - } - void sendPromise; - }, - [sendMessage, threadId], - ); - const handleSubmitHumanInput = useCallback( - async (request: HumanInputRequest, response: HumanInputResponse) => { - let sent = false; - await sendMessage( - threadId, - { - text: buildHumanInputResponseText(request, response), - files: [], - }, - undefined, - { - additionalKwargs: { - hide_from_ui: true, - human_input_response: response, - }, - onSent: () => { - sent = true; - }, - }, - ); - return sent; - }, - [sendMessage, threadId], - ); - const handleStop = useCallback(async () => { - await thread.stop(); - }, [thread]); - const handleRegenerate = useCallback( - (messageId: string, supersededMessageIds: string[]) => - regenerateMessage(threadId, messageId, supersededMessageIds), - [regenerateMessage, threadId], - ); - const handleEditAndRegenerate = useCallback( - (messageId: string, replacementText: string) => - editAndRegenerateMessage(threadId, messageId, replacementText), - [editAndRegenerateMessage, threadId], - ); - const handleBranchTurn = useCallback( - async (messageId: string, messageIds: string[]) => { - if ( - isNewThread || - isMock || - env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" - ) { - return; - } - - try { - const response = await branchThread.mutateAsync({ - threadId, - messageId, - messageIds, - }); - toast.success(t.conversation.branchCreated); - router.push(`/workspace/chats/${response.thread_id}`); - } catch (error) { - toast.error( - error instanceof Error ? error.message : t.conversation.branchFailed, - ); - } - }, - [branchThread, isMock, isNewThread, router, t, threadId], - ); - - const tokenUsageInlineMode = tokenUsageEnabled - ? localSettings.tokenUsage.inlineMode - : "off"; - const hasTodos = (thread.values.todos?.length ?? 0) > 0; - const browserEnabled = !isNewThread && !isMock && browserControlEnabled; - const { activeGoal, hasGoal, setLocalGoal } = useActiveGoal( - threadId, - thread.values.goal, - ); - const hasOpenHumanInputCard = useMemo( - () => - hasOpenHumanInputRequest( - thread.messages, - (message) => !isHiddenFromUIMessage(message), - ), - [thread.messages], - ); - - return ( - - - -
-
- {!isMock && } -
- -
-
- {!isNewThread && !isMock && ( - - )} - {tokenUsageEnabled ? ( - - setLocalSettings("tokenUsage", preferences) - } - /> - ) : ( - - )} - - {browserEnabled && } - - -
-
-
-
- -
-
-
- {(hasGoal || hasTodos) && ( -
-
- {activeGoal && } - {hasTodos && ( -
-
- )} - {mountedRef.current ? ( - - } - disabled={ - isMock || - env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || - isUploading || - (!isNewThread && isHistoryLoading) - } - onContextChange={(context) => - setSettings("context", context) - } - onGoalChange={setLocalGoal} - onSubmit={handleSubmit} - onStop={handleStop} - /> - ) : ( - -
-
-
-
-
-
- ); +export default function WorkspaceChatPage() { + return ; } diff --git a/frontend/src/components/workspace/chats/chat-page.tsx b/frontend/src/components/workspace/chats/chat-page.tsx new file mode 100644 index 000000000..f5668c690 --- /dev/null +++ b/frontend/src/components/workspace/chats/chat-page.tsx @@ -0,0 +1,462 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { ArtifactTrigger } from "@/components/workspace/artifacts"; +import { BrowserTrigger } from "@/components/workspace/browser-view"; +import { ContextUsageBadge } from "@/components/workspace/context-usage-badge"; +import { ExportTrigger } from "@/components/workspace/export-trigger"; +import { GoalStatus } from "@/components/workspace/goal-status"; +import { + InputBox, + type InputBoxSubmitOptions, +} from "@/components/workspace/input-box"; +import { + MessageList, + MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, +} from "@/components/workspace/messages"; +import { ThreadContext } from "@/components/workspace/messages/context"; +import { + SidecarProvider, + SidecarTrigger, +} from "@/components/workspace/sidecar"; +import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link"; +import { ThreadTitle } from "@/components/workspace/thread-title"; +import { TodoList } from "@/components/workspace/todo-list"; +import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; +import { useActiveGoal } from "@/components/workspace/use-active-goal"; +import { Welcome } from "@/components/workspace/welcome"; +import { useBrowserControlEnabled } from "@/core/features"; +import { useI18n } from "@/core/i18n/hooks"; +import { + buildHumanInputResponseText, + hasOpenHumanInputRequest, + type HumanInputRequest, + type HumanInputResponse, +} from "@/core/messages/human-input"; +import { isHiddenFromUIMessage } from "@/core/messages/utils"; +import { useModels } from "@/core/models/hooks"; +import { useNotification } from "@/core/notification/hooks"; +import { useLocalSettings, useThreadSettings } from "@/core/settings"; +import { + useBranchThread, + useThreadMetadata, + useThreadStream, + useThreadTokenUsage, +} from "@/core/threads/hooks"; +import { + selectContextUsage, + threadTokenUsageToTokenUsage, +} from "@/core/threads/token-usage"; +import { textOfMessage } from "@/core/threads/utils"; +import { env } from "@/env"; +import { cn } from "@/lib/utils"; + +import { ChatBox } from "./chat-box"; +import { useSpecificChatMode } from "./use-chat-mode"; +import { useThreadChat } from "./use-thread-chat"; + +export default function ChatPage() { + const { t } = useI18n(); + const router = useRouter(); + const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = + useThreadChat(); + // `isNewThread` tracks whether the backend has the thread yet — gates the + // SDK's history fetch (see issue #2746). `isWelcomeMode` is the visual + // welcome layout (centered input, hero, quick actions); we flip it to false + // the moment the user submits so the UI animates immediately, even though + // `isNewThread` stays true until the backend actually creates the thread. + const [isWelcomeMode, setIsWelcomeMode] = useState(isNewThread); + const [settings, setSettings] = useThreadSettings(threadId); + const [localSettings, setLocalSettings] = useLocalSettings(); + const { enabled: browserControlEnabled } = useBrowserControlEnabled(); + const { tokenUsageEnabled } = useModels(); + const threadTokenUsage = useThreadTokenUsage( + isNewThread || isMock ? undefined : threadId, + { enabled: !isMock }, + ); + const threadMetadata = useThreadMetadata(threadId, { + enabled: !isNewThread && !isMock, + isMock, + }); + const branchThread = useBranchThread(); + const backendTokenUsage = threadTokenUsageToTokenUsage(threadTokenUsage.data); + const contextUsage = selectContextUsage(threadTokenUsage.data); + const mountedRef = useRef(false); + useSpecificChatMode(); + + useEffect(() => { + mountedRef.current = true; + }, []); + + // Keep welcome layout in sync when navigating between threads (sidebar + // clicks, "new chat" button). Submitting in /chats/new flips the layout + // via onSend below — `isNewThread` stays true until onStart, so this effect + // is harmless during the submit transition. + useEffect(() => { + setIsWelcomeMode(isNewThread); + }, [isNewThread]); + + const { showNotification } = useNotification(); + + const { + thread, + pendingUsageMessages, + sendMessage, + regenerateMessage, + editAndRegenerateMessage, + isUploading, + isHistoryLoading, + hasMoreHistory, + loadMoreHistory, + } = useThreadStream({ + threadId: isNewThread ? undefined : threadId, + displayThreadId: threadId, + context: settings.context, + isMock, + // onSend only animates the UI; do NOT flip `isNewThread` here — the + // LangGraph SDK eagerly fetches /history the moment it receives a + // thread id and assumes the thread exists on the backend (issue #2746). + onSend: () => { + setIsWelcomeMode(false); + }, + onStart: (createdThreadId) => { + // ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead. + history.replaceState(null, "", `/workspace/chats/${createdThreadId}`); + setThreadId(createdThreadId); + setIsNewThread(false); + }, + onFinish: (state) => { + if (document.hidden || !document.hasFocus()) { + let body = "Conversation finished"; + const lastMessage = state.messages.at(-1); + if (lastMessage) { + const textContent = textOfMessage(lastMessage); + if (textContent) { + body = + textContent.length > 200 + ? textContent.substring(0, 200) + "..." + : textContent; + } + } + showNotification(state.title, { body }); + } + }, + }); + + const hasThreadMessages = thread.messages.length > 0; + + useEffect(() => { + if ( + !isNewThread && + !isMock && + threadMetadata.data === null && + !threadMetadata.isLoading && + !threadMetadata.isFetching && + !isHistoryLoading && + !hasMoreHistory && + !hasThreadMessages + ) { + router.replace("/workspace/chats/new"); + } + }, [ + hasMoreHistory, + hasThreadMessages, + isHistoryLoading, + isMock, + isNewThread, + router, + threadMetadata.data, + threadMetadata.isFetching, + threadMetadata.isLoading, + ]); + + const handleSubmit = useCallback( + (message: PromptInputMessage, options?: InputBoxSubmitOptions) => { + const sendPromise = sendMessage(threadId, message, undefined, options); + if (message.files.length > 0) { + return sendPromise; + } + void sendPromise; + }, + [sendMessage, threadId], + ); + const handleSubmitHumanInput = useCallback( + async (request: HumanInputRequest, response: HumanInputResponse) => { + let sent = false; + await sendMessage( + threadId, + { + text: buildHumanInputResponseText(request, response), + files: [], + }, + undefined, + { + additionalKwargs: { + hide_from_ui: true, + human_input_response: response, + }, + onSent: () => { + sent = true; + }, + }, + ); + return sent; + }, + [sendMessage, threadId], + ); + const handleStop = useCallback(async () => { + await thread.stop(); + }, [thread]); + const handleRegenerate = useCallback( + (messageId: string, supersededMessageIds: string[]) => + regenerateMessage(threadId, messageId, supersededMessageIds), + [regenerateMessage, threadId], + ); + const handleEditAndRegenerate = useCallback( + (messageId: string, replacementText: string) => + editAndRegenerateMessage(threadId, messageId, replacementText), + [editAndRegenerateMessage, threadId], + ); + const handleBranchTurn = useCallback( + async (messageId: string, messageIds: string[]) => { + if ( + isNewThread || + isMock || + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" + ) { + return; + } + + try { + const response = await branchThread.mutateAsync({ + threadId, + messageId, + messageIds, + }); + toast.success(t.conversation.branchCreated); + router.push(`/workspace/chats/${response.thread_id}`); + } catch (error) { + toast.error( + error instanceof Error ? error.message : t.conversation.branchFailed, + ); + } + }, + [branchThread, isMock, isNewThread, router, t, threadId], + ); + + const tokenUsageInlineMode = tokenUsageEnabled + ? localSettings.tokenUsage.inlineMode + : "off"; + const hasTodos = (thread.values.todos?.length ?? 0) > 0; + const browserEnabled = !isNewThread && !isMock && browserControlEnabled; + const { activeGoal, hasGoal, setLocalGoal } = useActiveGoal( + threadId, + thread.values.goal, + ); + const hasOpenHumanInputCard = useMemo( + () => + hasOpenHumanInputRequest( + thread.messages, + (message) => !isHiddenFromUIMessage(message), + ), + [thread.messages], + ); + + return ( + + + +
+
+ {!isMock && } +
+ +
+
+ {!isNewThread && !isMock && ( + + )} + {tokenUsageEnabled ? ( + + setLocalSettings("tokenUsage", preferences) + } + /> + ) : ( + + )} + + {browserEnabled && } + + +
+
+
+
+ +
+
+
+ {(hasGoal || hasTodos) && ( +
+
+ {activeGoal && } + {hasTodos && ( +
+
+ )} + {mountedRef.current ? ( + + } + disabled={ + isMock || + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || + isUploading || + (!isNewThread && isHistoryLoading) + } + onContextChange={(context) => + setSettings("context", context) + } + onGoalChange={setLocalGoal} + onSubmit={handleSubmit} + onStop={handleStop} + /> + ) : ( + +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/workspace/chats/use-thread-chat.ts b/frontend/src/components/workspace/chats/use-thread-chat.ts index 4c2cddc50..3a324d5b9 100644 --- a/frontend/src/components/workspace/chats/use-thread-chat.ts +++ b/frontend/src/components/workspace/chats/use-thread-chat.ts @@ -3,6 +3,7 @@ import { useParams, usePathname, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; +import { SHOWCASE_ROUTE_PREFIX } from "@/core/threads/static-demo"; import { uuid } from "@/core/utils/uuid"; export const THREAD_CHAT_RESET_EVENT = "deer-flow:thread-chat-reset"; @@ -120,7 +121,7 @@ export function useThreadChat() { }, []); const isMock = - actualPathname.startsWith("/showcase/") || + actualPathname.startsWith(`${SHOWCASE_ROUTE_PREFIX}/`) || searchParams.get("mock") === "true"; return { threadId: isNewPath ? (newThreadIdRef.current ?? threadId) : threadId, diff --git a/frontend/src/core/threads/static-demo.ts b/frontend/src/core/threads/static-demo.ts index e0894af73..5e056379f 100644 --- a/frontend/src/core/threads/static-demo.ts +++ b/frontend/src/core/threads/static-demo.ts @@ -19,6 +19,8 @@ export const DEMO_THREAD_IDS = [ "fe3f7974-1bcb-4a01-a950-79673baafefd", ] as const; +export const SHOWCASE_ROUTE_PREFIX = "/showcase"; + export const STATIC_DEMO_ARTIFACTS: Readonly< Record > = { @@ -132,7 +134,7 @@ export function isDemoThreadId(threadId: string): boolean { } export function pathOfPublicDemoThread(threadId: string): string { - return `/showcase/${encodeURIComponent(threadId)}`; + return `${SHOWCASE_ROUTE_PREFIX}/${encodeURIComponent(threadId)}`; } export type ThreadSearchParams = NonNullable<