refactor(frontend): share showcase chat page (#4765)

This commit is contained in:
Daoyuan Li 2026-08-11 18:16:58 -07:00 committed by GitHub
parent 6cbf20fd39
commit e23dd8f88b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 471 additions and 464 deletions

View File

@ -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;

View File

@ -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 (
<ThreadContext.Provider value={{ thread, isMock }}>
<SidecarProvider
parentThreadId={threadId}
context={settings.context}
isMock={isMock}
>
<ChatBox threadId={threadId} browserEnabled={browserEnabled}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
isWelcomeMode
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
{!isMock && <SidebarTrigger className="md:hidden" />}
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex shrink-0 items-center gap-2">
{!isNewThread && !isMock && (
<ThreadScheduledTasksLink threadId={threadId} />
)}
{tokenUsageEnabled ? (
<TokenUsageIndicator
threadId={isNewThread ? undefined : threadId}
backendUsage={backendTokenUsage}
contextUsage={contextUsage}
enabled={tokenUsageEnabled}
messages={thread.messages}
pendingMessages={pendingUsageMessages}
preferences={localSettings.tokenUsage}
onPreferencesChange={(preferences) =>
setLocalSettings("tokenUsage", preferences)
}
/>
) : (
<ContextUsageBadge contextUsage={contextUsage} />
)}
<SidecarTrigger />
{browserEnabled && <BrowserTrigger />}
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
className={cn("size-full", !isWelcomeMode && "pt-10")}
testId="main-message-list"
threadId={threadId}
thread={thread}
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
hasMoreHistory={hasMoreHistory}
loadMoreHistory={loadMoreHistory}
isHistoryLoading={isHistoryLoading}
tokenUsageInlineMode={tokenUsageInlineMode}
canRegenerate={
!isNewThread &&
!isMock &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
!isUploading &&
!thread.isLoading
}
onRegenerateMessage={handleRegenerate}
canEdit={
!isNewThread &&
!isMock &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
!isUploading &&
!thread.isLoading &&
!branchThread.isPending &&
!hasGoal &&
!hasOpenHumanInputCard
}
onEditAndRegenerateMessage={handleEditAndRegenerate}
onSubmitHumanInput={
isMock || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
? undefined
: handleSubmitHumanInput
}
canBranch={
!isNewThread &&
!isMock &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
!isUploading &&
!thread.isLoading &&
!branchThread.isPending
}
onBranchTurn={handleBranchTurn}
/>
</div>
<div
className={cn(
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
)}
>
<div
className={cn(
"relative w-full",
isWelcomeMode &&
"-translate-y-[calc(50vh-48px)] sm:-translate-y-[calc(50vh-96px)]",
isWelcomeMode
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
{(hasGoal || hasTodos) && (
<div
className={cn(
"right-0 left-0 z-0",
isWelcomeMode ? "absolute -top-4" : "relative",
)}
>
<div
className={cn(
"right-0 bottom-0 left-0 flex flex-col",
isWelcomeMode ? "absolute" : "relative",
)}
>
{activeGoal && <GoalStatus goal={activeGoal} />}
{hasTodos && (
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={false}
/>
)}
</div>
</div>
)}
{mountedRef.current ? (
<InputBox
className={cn(
"bg-background/5 w-full",
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
)}
isWelcomeMode={isWelcomeMode}
threadId={threadId}
draftThreadId={isNewThread ? "new" : threadId}
autoFocus={isWelcomeMode}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isWelcomeMode &&
!hasGoal &&
!hasTodos && <Welcome mode={settings.context.mode} />
}
disabled={
isMock ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading ||
(!isNewThread && isHistoryLoading)
}
onContextChange={(context) =>
setSettings("context", context)
}
onGoalChange={setLocalGoal}
onSubmit={handleSubmit}
onStop={handleStop}
/>
) : (
<div
aria-hidden="true"
className={cn(
"bg-background/5 h-32 w-full rounded-2xl",
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
)}
/>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</SidecarProvider>
</ThreadContext.Provider>
);
export default function WorkspaceChatPage() {
return <ChatPage />;
}

View File

@ -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 (
<ThreadContext.Provider value={{ thread, isMock }}>
<SidecarProvider
parentThreadId={threadId}
context={settings.context}
isMock={isMock}
>
<ChatBox threadId={threadId} browserEnabled={browserEnabled}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
isWelcomeMode
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
{!isMock && <SidebarTrigger className="md:hidden" />}
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex shrink-0 items-center gap-2">
{!isNewThread && !isMock && (
<ThreadScheduledTasksLink threadId={threadId} />
)}
{tokenUsageEnabled ? (
<TokenUsageIndicator
threadId={isNewThread ? undefined : threadId}
backendUsage={backendTokenUsage}
contextUsage={contextUsage}
enabled={tokenUsageEnabled}
messages={thread.messages}
pendingMessages={pendingUsageMessages}
preferences={localSettings.tokenUsage}
onPreferencesChange={(preferences) =>
setLocalSettings("tokenUsage", preferences)
}
/>
) : (
<ContextUsageBadge contextUsage={contextUsage} />
)}
<SidecarTrigger />
{browserEnabled && <BrowserTrigger />}
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
className={cn("size-full", !isWelcomeMode && "pt-10")}
testId="main-message-list"
threadId={threadId}
thread={thread}
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
hasMoreHistory={hasMoreHistory}
loadMoreHistory={loadMoreHistory}
isHistoryLoading={isHistoryLoading}
tokenUsageInlineMode={tokenUsageInlineMode}
canRegenerate={
!isNewThread &&
!isMock &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
!isUploading &&
!thread.isLoading
}
onRegenerateMessage={handleRegenerate}
canEdit={
!isNewThread &&
!isMock &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
!isUploading &&
!thread.isLoading &&
!branchThread.isPending &&
!hasGoal &&
!hasOpenHumanInputCard
}
onEditAndRegenerateMessage={handleEditAndRegenerate}
onSubmitHumanInput={
isMock || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
? undefined
: handleSubmitHumanInput
}
canBranch={
!isNewThread &&
!isMock &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
!isUploading &&
!thread.isLoading &&
!branchThread.isPending
}
onBranchTurn={handleBranchTurn}
/>
</div>
<div
className={cn(
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
)}
>
<div
className={cn(
"relative w-full",
isWelcomeMode &&
"-translate-y-[calc(50vh-48px)] sm:-translate-y-[calc(50vh-96px)]",
isWelcomeMode
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
{(hasGoal || hasTodos) && (
<div
className={cn(
"right-0 left-0 z-0",
isWelcomeMode ? "absolute -top-4" : "relative",
)}
>
<div
className={cn(
"right-0 bottom-0 left-0 flex flex-col",
isWelcomeMode ? "absolute" : "relative",
)}
>
{activeGoal && <GoalStatus goal={activeGoal} />}
{hasTodos && (
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={false}
/>
)}
</div>
</div>
)}
{mountedRef.current ? (
<InputBox
className={cn(
"bg-background/5 w-full",
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
)}
isWelcomeMode={isWelcomeMode}
threadId={threadId}
draftThreadId={isNewThread ? "new" : threadId}
autoFocus={isWelcomeMode}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isWelcomeMode &&
!hasGoal &&
!hasTodos && <Welcome mode={settings.context.mode} />
}
disabled={
isMock ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading ||
(!isNewThread && isHistoryLoading)
}
onContextChange={(context) =>
setSettings("context", context)
}
onGoalChange={setLocalGoal}
onSubmit={handleSubmit}
onStop={handleStop}
/>
) : (
<div
aria-hidden="true"
className={cn(
"bg-background/5 h-32 w-full rounded-2xl",
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
)}
/>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</SidecarProvider>
</ThreadContext.Provider>
);
}

View File

@ -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,

View File

@ -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<string, readonly string[]>
> = {
@ -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<