mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 14:06:18 +00:00
* feat(plugins): add full-stack contributions and bookmarks example * ci(plugins): provision bookmark gateway for browser tests * fix(plugins): authenticate module downloads through configured backend * fix(plugins): isolate contributions and localize extension UI * fix(plugins): preserve bookmark agent routing and contain async callbacks * fix(plugins): pin durable batch workers to app extension snapshots
688 lines
26 KiB
TypeScript
688 lines
26 KiB
TypeScript
"use client";
|
|
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { Folder } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useRouter, useSearchParams } 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 { KnowledgeScopeSelector } from "@/components/workspace/knowledge-scope-selector";
|
|
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 { ThreadArchiveStatus } from "@/components/workspace/thread-archive-status";
|
|
import { ThreadBackgroundTasks } from "@/components/workspace/thread-background-tasks";
|
|
import { ThreadExtensionActions } from "@/components/workspace/thread-extension-actions";
|
|
import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link";
|
|
import { ThreadSubagentBatches } from "@/components/workspace/thread-subagent-batches";
|
|
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 { useAuth } from "@/core/auth/AuthProvider";
|
|
import { hasPermission, PERMISSIONS } from "@/core/auth/permissions";
|
|
import {
|
|
useBrowserControlEnabled,
|
|
useKnowledgeBaseEnabled,
|
|
} from "@/core/features";
|
|
import { useI18n } from "@/core/i18n/hooks";
|
|
import {
|
|
ALL_KNOWLEDGE_SCOPE,
|
|
buildKnowledgeScopeSnapshot,
|
|
KNOWLEDGE_SCOPE_KEY,
|
|
type KnowledgeScopeSelection,
|
|
} from "@/core/knowledge";
|
|
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 { useProject } from "@/core/projects";
|
|
import { useLocalSettings, useThreadSettings } from "@/core/settings";
|
|
import { resolveThreadContext } from "@/core/settings/store";
|
|
import { createThread } from "@/core/threads/api";
|
|
import {
|
|
useBranchThread,
|
|
INFINITE_THREADS_QUERY_KEY_PREFIX,
|
|
useThreadMetadata,
|
|
useThreadStream,
|
|
useThreadTokenUsage,
|
|
} from "@/core/threads/hooks";
|
|
import {
|
|
selectContextUsage,
|
|
threadTokenUsageToTokenUsage,
|
|
} from "@/core/threads/token-usage";
|
|
import { projectIdOfThread, 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 { user } = useAuth();
|
|
const canStopStreaming = hasPermission(user, PERMISSIONS.RUNS_CANCEL);
|
|
const canCreateRuns = hasPermission(user, PERMISSIONS.RUNS_CREATE);
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
|
useThreadChat();
|
|
// Project-scoped new chat: `/workspace/chats/new?project={id}` assigns the
|
|
// thread to the project on the FIRST submit — primarily via an explicit
|
|
// `POST /api/threads` pre-create with `project_id`, with the run-request
|
|
// metadata seed as the fallback channel (the SDK's own threads.create
|
|
// strips the reserved key, so the seed alone races the sidebar). Only
|
|
// meaningful while the thread is still lazy (`isNewThread`); once
|
|
// materialized the URL is replaced with the thread route and the param is
|
|
// gone. Invalid ids are dropped by backend admission.
|
|
const projectParam = isNewThread ? searchParams.get("project") : null;
|
|
// `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 queryClient = useQueryClient();
|
|
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 { scopeSelectionEnabled } = useKnowledgeBaseEnabled();
|
|
const selectorVisible =
|
|
scopeSelectionEnabled && env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true";
|
|
const [knowledgeScope, setKnowledgeScope] =
|
|
useState<KnowledgeScopeSelection | null>(null);
|
|
const previousConversationRef = useRef({ threadId, isNewThread });
|
|
|
|
useEffect(() => {
|
|
setKnowledgeScope((current) => {
|
|
if (!selectorVisible) return null;
|
|
return current ?? ALL_KNOWLEDGE_SCOPE;
|
|
});
|
|
}, [selectorVisible]);
|
|
|
|
useEffect(() => {
|
|
const previous = previousConversationRef.current;
|
|
if (
|
|
previous.threadId !== threadId ||
|
|
previous.isNewThread !== isNewThread
|
|
) {
|
|
const isNewThreadRouteReplacement = previous.isNewThread && !isNewThread;
|
|
if (!isNewThreadRouteReplacement) {
|
|
setKnowledgeScope(selectorVisible ? ALL_KNOWLEDGE_SCOPE : null);
|
|
}
|
|
}
|
|
previousConversationRef.current = { threadId, isNewThread };
|
|
}, [isNewThread, selectorVisible, threadId]);
|
|
|
|
const currentKnowledgeScopeSnapshot = useMemo(
|
|
() =>
|
|
selectorVisible && knowledgeScope
|
|
? buildKnowledgeScopeSnapshot(knowledgeScope)
|
|
: null,
|
|
[knowledgeScope, selectorVisible],
|
|
);
|
|
|
|
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,
|
|
]);
|
|
|
|
// Born assigned: pre-create the thread row with its project so the sidebar
|
|
// lists it under the project immediately. Idempotent server-side on
|
|
// `thread_id`, so retrying the same first message (same `threadId` while
|
|
// `isNewThread`) reuses the existing row instead of double-creating. This
|
|
// is the sole membership channel — run requests never carry the project
|
|
// key.
|
|
//
|
|
// Also runs before InputBox issues a `/goal <condition>` PUT (via
|
|
// `onPrepareThread`): the goal endpoint materializes a missing thread row
|
|
// itself, and an unassigned row would make this later idempotent create a
|
|
// membership no-op.
|
|
const ensureProjectThread = useCallback(async () => {
|
|
if (!projectParam) {
|
|
return;
|
|
}
|
|
try {
|
|
await createThread(threadId, projectParam);
|
|
void queryClient.invalidateQueries({
|
|
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
|
});
|
|
} catch (error) {
|
|
// Any failure (e.g. the project was deleted/archived between page load
|
|
// and submit): do NOT submit unassigned. Reject so PromptInput keeps
|
|
// the composer's text for a retry; the send in-flight guard is never
|
|
// engaged on this path, so retrying works immediately.
|
|
toast.error(t.projects.projectUnavailable);
|
|
throw error;
|
|
}
|
|
}, [threadId, projectParam, queryClient, t]);
|
|
|
|
// Submission fence. The cleanup runs when `threadId` changes (conversation
|
|
// switch on a persisted page — sidebar navigation keeps this component
|
|
// mounted), when the new-chat project scope changes (the sidebar "New
|
|
// chat" link can drop `?project=` without a pathname change), or on
|
|
// unmount. handleSubmit awaits the project pre-create before sending, and
|
|
// a navigation during that await must not let the stale continuation
|
|
// start a run for the abandoned conversation: its onStart would rewrite
|
|
// the newly selected conversation's URL and its completion would clear
|
|
// the new composer.
|
|
const submissionEpochRef = useRef(0);
|
|
useEffect(() => {
|
|
return () => {
|
|
submissionEpochRef.current += 1;
|
|
};
|
|
}, [threadId, projectParam]);
|
|
|
|
const handleSubmit = useCallback(
|
|
async (message: PromptInputMessage, options?: InputBoxSubmitOptions) => {
|
|
const submissionEpoch = submissionEpochRef.current;
|
|
await ensureProjectThread();
|
|
// Conversation switched (or the page unmounted) while the project
|
|
// pre-create was pending: drop the submission. Reject silently — the
|
|
// user has already moved on, so a toast would land on the new
|
|
// conversation — and PromptInput keeps the current composer text.
|
|
if (submissionEpochRef.current !== submissionEpoch) {
|
|
throw new Error("thread-submission-stale");
|
|
}
|
|
const scopedOptions = currentKnowledgeScopeSnapshot
|
|
? {
|
|
...options,
|
|
additionalKwargs: {
|
|
...options?.additionalKwargs,
|
|
[KNOWLEDGE_SCOPE_KEY]: currentKnowledgeScopeSnapshot,
|
|
},
|
|
}
|
|
: options;
|
|
const sendPromise = sendMessage(
|
|
threadId,
|
|
message,
|
|
undefined,
|
|
scopedOptions,
|
|
);
|
|
if (message.files.length > 0) {
|
|
return sendPromise;
|
|
}
|
|
void sendPromise;
|
|
},
|
|
[currentKnowledgeScopeSnapshot, sendMessage, threadId, ensureProjectThread],
|
|
);
|
|
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,
|
|
...(currentKnowledgeScopeSnapshot
|
|
? { [KNOWLEDGE_SCOPE_KEY]: currentKnowledgeScopeSnapshot }
|
|
: {}),
|
|
},
|
|
onSent: () => {
|
|
sent = true;
|
|
},
|
|
},
|
|
);
|
|
return sent;
|
|
},
|
|
[currentKnowledgeScopeSnapshot, 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,
|
|
currentKnowledgeScopeSnapshot
|
|
? { [KNOWLEDGE_SCOPE_KEY]: currentKnowledgeScopeSnapshot }
|
|
: undefined,
|
|
),
|
|
[currentKnowledgeScopeSnapshot, 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],
|
|
);
|
|
|
|
// Project affiliation chip: shown once the materialized thread's metadata
|
|
// carries `deerflow_project_id` (written by the create/move endpoints and
|
|
// exposed here read-only).
|
|
const affiliatedProjectId =
|
|
!isNewThread && !isMock && threadMetadata.data
|
|
? projectIdOfThread(threadMetadata.data)
|
|
: null;
|
|
|
|
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 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
|
|
isWelcomeMode
|
|
? "bg-background/0 z-40 backdrop-blur-none"
|
|
: "bg-background/80 z-30 shadow-xs backdrop-blur",
|
|
)}
|
|
>
|
|
{!isMock && <SidebarTrigger className="md:hidden" />}
|
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm font-medium">
|
|
<ThreadTitle
|
|
threadId={threadId}
|
|
thread={thread}
|
|
canonicalTitle={threadMetadata.data?.values?.title}
|
|
/>
|
|
{!isNewThread &&
|
|
!isMock &&
|
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
|
<ThreadArchiveStatus
|
|
threadId={threadId}
|
|
metadata={threadMetadata.data?.metadata}
|
|
/>
|
|
)}
|
|
{affiliatedProjectId && (
|
|
<ProjectAffiliationBadge projectId={affiliatedProjectId} />
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{!isNewThread &&
|
|
!isMock &&
|
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
|
<ThreadBackgroundTasks threadId={threadId} />
|
|
)}
|
|
{!isNewThread &&
|
|
!isMock &&
|
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
|
<ThreadSubagentBatches threadId={threadId} />
|
|
)}
|
|
{!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} />
|
|
<ThreadExtensionActions 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
|
|
archiveDownloadsEnabled={
|
|
isNewThread || isMock || threadMetadata.data != null
|
|
}
|
|
className={cn("size-full", !isWelcomeMode && "pt-10")}
|
|
testId="main-message-list"
|
|
threadId={threadId}
|
|
thread={thread}
|
|
enableConversationOutline
|
|
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}
|
|
knowledgeScopeControl={
|
|
selectorVisible && knowledgeScope ? (
|
|
<KnowledgeScopeSelector
|
|
agentName="lead_agent"
|
|
disabled={thread.isLoading || isUploading}
|
|
selection={knowledgeScope}
|
|
onChange={setKnowledgeScope}
|
|
/>
|
|
) : undefined
|
|
}
|
|
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, options) => {
|
|
if (options?.automatic)
|
|
resolveThreadContext(threadId, context);
|
|
else setSettings("context", context);
|
|
}}
|
|
onGoalChange={setLocalGoal}
|
|
onPrepareThread={ensureProjectThread}
|
|
onSubmit={handleSubmit}
|
|
onStop={handleStop}
|
|
canStopStreaming={canStopStreaming}
|
|
canCreateRuns={canCreateRuns}
|
|
/>
|
|
) : (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Small chip in the chat header linking to the thread's project. Hidden
|
|
* while the project lookup is pending or when it fails (e.g. the project
|
|
* was deleted) — an unresolvable affiliation degrades silently.
|
|
*/
|
|
function ProjectAffiliationBadge({ projectId }: { projectId: string }) {
|
|
const { data: project } = useProject(projectId);
|
|
if (!project) {
|
|
return null;
|
|
}
|
|
return (
|
|
<Link
|
|
href={`/workspace/projects/${encodeURIComponent(project.id)}`}
|
|
className="text-muted-foreground hover:text-foreground inline-flex max-w-40 shrink-0 items-center gap-1 truncate rounded-full border px-2 py-0.5 text-xs font-normal transition-colors"
|
|
>
|
|
<Folder className="size-3 shrink-0" />
|
|
<span className="truncate">{project.name}</span>
|
|
</Link>
|
|
);
|
|
}
|