Files
onyx/web/src/views/AppPage.tsx
T

1097 lines
41 KiB
TypeScript

"use client";
import {
redirect,
usePathname,
useRouter,
useSearchParams,
} from "next/navigation";
import { loginPath } from "@/lib/auth/paths";
import { endIncognitoSession } from "@/app/app/services/lib";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams";
import { Section } from "@/layouts/general-layouts";
import { useFederatedConnectors, useLlmManager } from "@/lib/hooks";
import { useSendChatMessageFromURL } from "@/lib/chat/hooks";
import OnyxInitializingLoader from "@/components/OnyxInitializingLoader";
import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/types";
import { useToolConfiguration } from "@/lib/tools/hooks";
import { useSettings } from "@/lib/settings/hooks";
import Dropzone from "react-dropzone";
import AppInputBar, { AppInputBarHandle } from "@/sections/input/AppInputBar";
import useChatSessions from "@/hooks/useChatSessions";
import useCCPairs from "@/hooks/useCCPairs";
import { useDocumentSets } from "@/lib/hooks/useDocumentSets";
import { useAgents } from "@/lib/agents/hooks";
import { AppPopup } from "@/app/app/components/AppPopup";
import { useUser } from "@/providers/UserProvider";
import { useCurrentUser } from "@/lib/users/hooks";
import { NoAgentModal } from "@/lib/agents/components";
import PreviewModal from "@/sections/modals/PreviewModal";
import { Modal } from "@opal/components";
import { useSendMessageToParent } from "@/lib/extension/hooks";
import { SourceMetadata } from "@/lib/search/types";
import { FederatedConnectorDetail } from "@/lib/types";
import { ValidSources } from "@/lib/connectors/types/source";
import DocumentsSidebar from "@/sections/document-sidebar/DocumentsSidebar";
import useChatController from "@/hooks/useChatController";
import useMultiModelChat from "@/hooks/useMultiModelChat";
import MultiModelSelector from "@/sections/model-selector/MultiModelSelector";
import { useActiveAgent } from "@/lib/agents/hooks";
import useChatSessionController from "@/hooks/useChatSessionController";
import useDeepResearchToggle from "@/hooks/useDeepResearchToggle";
import { useIncognito } from "@/providers/IncognitoProvider";
import { isAssistant } from "@/lib/agents/utils";
import AgentDescription from "@/app/app/components/AgentDescription";
import {
useChatSessionStore,
useCurrentMessageHistory,
useCurrentMessageTree,
} from "@/app/app/stores/useChatSessionStore";
import {
useCurrentChatState,
useIsReady,
useDocumentSidebarVisible,
useCurrentIsStreamDraining,
} from "@/app/app/stores/useChatSessionStore";
import FederatedOAuthModal from "@/components/chat/FederatedOAuthModal";
import ChatScrollContainer, {
ChatScrollContainerHandle,
} from "@/sections/chat/ChatScrollContainer";
import {
ProjectContextPanel,
ProjectChatSessionList,
} from "@/lib/projects/components";
import { useProjectsContext } from "@/lib/projects/providers";
import { useActiveProject, useProjects } from "@/lib/projects/hooks";
import { getProjectTokenCount } from "@/lib/projects/svc";
import { cn } from "@opal/utils";
import Suggestions from "@/sections/Suggestions";
import OnboardingFlow from "@/sections/onboarding/OnboardingFlow";
import { OnboardingStep } from "@/interfaces/onboarding";
import { useShowOnboarding } from "@/hooks/useShowOnboarding";
import { SvgChevronDown, SvgFileText } from "@opal/icons";
import { Button, ShadowDiv, Spacer } from "@opal/components";
import {
IllustrationContent,
RootLayout,
toast,
useToastFromQuery,
} from "@opal/layouts";
import { SvgNotFound, SvgNoAccess } from "@opal/illustrations";
import { useChatSessionSupportsRetrieval } from "@/lib/app/hooks";
import { useAppPosition } from "@/lib/position/hooks";
import useScreenSize from "@/hooks/useScreenSize";
import { useSidebarState } from "@opal/layouts";
import { useQueryController } from "@/providers/QueryControllerProvider";
import WelcomeMessage from "@/app/app/components/WelcomeMessage";
import ChatUI from "@/sections/chat/ChatUI";
import { useFullWidthChat } from "@/providers/FullWidthChatProvider";
import { paidTierGated } from "@/ce";
import EESearchUI from "@/ee/sections/SearchUI";
const SearchUI = paidTierGated(EESearchUI);
import { motion, AnimatePresence } from "motion/react";
import { useTranslations } from "next-intl";
interface FadeProps {
show: boolean;
children?: React.ReactNode;
className?: string;
}
function Fade({ show, children, className }: FadeProps) {
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={className}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}
export interface ChatPageProps {
firstMessage?: string;
}
export default function AppPage({ firstMessage }: ChatPageProps) {
// Performance tracking
// Keeping this here in case we need to track down slow renders in the future
// const renderCount = useRef(0);
// renderCount.current++;
// const renderStartTime = performance.now();
// useEffect(() => {
// const renderTime = performance.now() - renderStartTime;
// if (renderTime > 10) {
// console.log(
// `[ChatPage] Slow render #${renderCount.current}: ${renderTime.toFixed(
// 2
// )}ms`
// );
// }
// });
const t = useTranslations("chat.app");
const router = useRouter();
const appPosition = useAppPosition();
const { isMobile } = useScreenSize();
useToastFromQuery({
oauth_connected: {
message: t("oauthConnected.toast"),
type: "success",
},
});
const searchParams = useSearchParams();
const pathname = usePathname();
// Use SWR hooks for data fetching
const {
chatSessions,
refreshChatSessions,
currentChatSession,
currentChatSessionId,
isLoading: isLoadingChatSessions,
} = useChatSessions();
const { vectorDbEnabled, disable_default_assistant } = useSettings();
const {
currentMessageFiles,
setCurrentMessageFiles,
currentProjectId,
currentProjectDetails,
lastFailedFiles,
clearLastFailedFiles,
} = useProjectsContext();
const isInitialLoad = useRef(true);
const { agents, isLoading: isLoadingAgents } = useAgents();
const { user } = useUser();
// `useUser()` reports null while loading, so gating on it would redirect during
// the /me load window. Read the raw result instead (undefined = loading, null =
// resolved signed-out). This matters for anonymous users specifically: they're
// kept on the login page, so unlike logged-in users they wouldn't bounce back.
const { user: resolvedUser } = useCurrentUser();
const activeAgent = useActiveAgent();
// An explicit agent pick supersedes project context — the two cannot both
// scope a new chat. This used to ride on the agent-selection callback, but
// it is a URL concern, so it is stated against the URL.
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString() || "");
if (
params.has(SEARCH_PARAM_NAMES.AGENT_ID) &&
params.has(SEARCH_PARAM_NAMES.PROJECT_ID)
) {
params.delete(SEARCH_PARAM_NAMES.PROJECT_ID);
router.replace(`?${params.toString()}`, { scroll: false });
}
}, [searchParams, router]);
const toolConfiguration = useToolConfiguration();
const { deepResearchEnabled, toggleDeepResearch } = useDeepResearchToggle({
chatSessionId: currentChatSessionId,
agentId: activeAgent?.id,
});
// Incognito lives in context so the top-bar toggle and this page stay in
// sync. This page owns the derived lock, the teardown on leaving a session,
// and the unload beacon.
const {
incognitoEnabled,
incognitoEnabledRef,
setIncognitoEnabled,
setIncognitoLocked,
} = useIncognito();
// Resolved from the chat, not the URL: `projectId` is dropped once a chat
// opens (`PARAMS_TO_SKIP` in `app/app/services/lib.tsx`), so `currentProjectId`
// is null inside a project chat. This value is what reaches the backend, so
// reading the search param let a project chat send deep research and error.
const { isLoading: isLoadingProjects } = useProjects();
const activeProject = useActiveProject();
// Withhold until the projects snapshot has loaded: an unloaded list makes a
// project chat look like a normal one, and this value reaches the backend.
const deepResearchEnabledForCurrentWorkflow =
!isLoadingProjects && activeProject === null && deepResearchEnabled;
const [presentingDocument, setPresentingDocument] =
useState<MinimalOnyxDocument | null>(null);
const llmManager = useLlmManager(
currentChatSession ?? undefined,
activeAgent
);
const {
showOnboarding,
onboardingDismissed,
onboardingState,
onboardingActions,
isLoadingOnboarding,
finishOnboarding,
hideOnboarding,
} = useShowOnboarding({
activeAgent,
isLoadingChatSessions,
chatSessionsCount: chatSessions.length,
userId: user?.id,
});
// Show toast if any files failed in ProjectsContext reconciliation
useEffect(() => {
if (lastFailedFiles && lastFailedFiles.length > 0) {
const names = lastFailedFiles.map((f) => f.name).join(", ");
toast.error(
t("failedFiles.toast", { count: lastFailedFiles.length, names })
);
clearLastFailedFiles();
}
}, [lastFailedFiles, clearLastFailedFiles, t]);
const chatInputBarRef = useRef<AppInputBarHandle>(null);
// An unresolved agent reads as plain chat, so a named-agent layout never
// flashes for an agent that is not there yet.
const isPlainChat = !activeAgent || isAssistant(activeAgent);
const scrollContainerRef = useRef<ChatScrollContainerHandle>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
// Reset scroll button when session changes
useEffect(() => {
setShowScrollButton(false);
}, [currentChatSessionId]);
const handleScrollToBottom = useCallback(() => {
scrollContainerRef.current?.scrollToBottom();
}, []);
const resetInputBar = useCallback(() => {
chatInputBarRef.current?.reset();
setCurrentMessageFiles([]);
}, [setCurrentMessageFiles]);
// Add refs needed by useChatSessionController
const chatSessionIdRef = useRef<string | null>(currentChatSessionId);
const loadedIdSessionRef = useRef<string | null>(currentChatSessionId);
const submitOnLoadPerformed = useRef<boolean>(false);
const [selectedDocuments, setSelectedDocuments] = useState<OnyxDocument[]>(
[]
);
// Access chat state directly from the store
const currentChatState = useCurrentChatState();
const isReady = useIsReady();
const documentSidebarVisible = useDocumentSidebarVisible();
const updateCurrentDocumentSidebarVisible = useChatSessionStore(
(state) => state.updateCurrentDocumentSidebarVisible
);
const messageHistory = useCurrentMessageHistory();
const messageTree = useCurrentMessageTree();
// The mode pins on creation, so lock the toggle whenever a session exists,
// even an empty one: submitting would reuse it with its pinned mode.
useEffect(() => {
setIncognitoLocked(
messageHistory.length > 0 || currentChatSessionId !== null
);
}, [messageHistory.length, currentChatSessionId, setIncognitoLocked]);
// Leaving an incognito session tears it down, whether the user goes to a
// fresh chat or straight into another one. Only the id changing tells us
// this happened, since incognito sessions never enter the sessions list.
const prevSessionIdForIncognito = useRef(currentChatSessionId);
useEffect(() => {
const previous = prevSessionIdForIncognito.current;
prevSessionIdForIncognito.current = currentChatSessionId;
if (!previous || previous === currentChatSessionId) return;
if (!incognitoEnabledRef.current) return;
// Incognito must clear either way: the user is now in a different chat and
// the badge would lie. A failure has no client retry path from here, so the
// orphan sweep is what eventually deletes the context and its uploads.
void endIncognitoSession(previous).then((tornDown) => {
if (!tornDown) {
console.error(
`Incognito teardown failed for ${previous}; leaving it to the server sweep`
);
}
});
setIncognitoEnabled(false);
}, [currentChatSessionId, setIncognitoEnabled]);
// Best-effort teardown when the tab closes over a live incognito session.
// sendBeacon survives unload where fetch would be cancelled.
useEffect(() => {
if (!incognitoEnabled || !currentChatSessionId) return;
const sessionId = currentChatSessionId;
const handlePageHide = () => {
navigator.sendBeacon(`/api/chat/end-incognito-session/${sessionId}`);
};
window.addEventListener("pagehide", handlePageHide);
return () => window.removeEventListener("pagehide", handlePageHide);
}, [incognitoEnabled, currentChatSessionId]);
// Determine anchor: second-to-last message (last user message before current response)
const anchorMessage = messageHistory.at(-2) ?? messageHistory[0];
const anchorNodeId = anchorMessage?.nodeId;
const anchorSelector = anchorNodeId ? `#message-${anchorNodeId}` : undefined;
// Auto-scroll preference from user settings. Pause while the
// typewriter is running its post-finish adaptive drain — the user is
// reading at that point and a scroll yank as the typewriter speeds up
// is jarring.
const autoScrollPreference = user?.preferences?.auto_scroll !== false;
const isStreamDraining = useCurrentIsStreamDraining();
const autoScrollEnabled = autoScrollPreference && !isStreamDraining;
const isStreaming = currentChatState === "streaming";
const multiModel = useMultiModelChat(llmManager);
const { fullWidthChat } = useFullWidthChat();
// Full-width applies in a conversation and on the new-session view, where
// it widens the greeting row and the composer.
const fullWidthActive =
fullWidthChat &&
((appPosition.isChat() && !!currentChatSessionId) ||
appPosition.isNewSession());
// Auto-fold sidebar when a multi-model message is submitted.
// Stays collapsed until the user exits multi-model mode (removes models).
const { folded: sidebarFolded, setFolded } = useSidebarState();
const preMultiModelFoldedRef = useRef<boolean | null>(null);
const foldSidebarForMultiModel = useCallback(() => {
if (preMultiModelFoldedRef.current === null) {
preMultiModelFoldedRef.current = sidebarFolded;
setFolded(true);
}
}, [sidebarFolded, setFolded]);
// Restore sidebar when user exits multi-model mode
useEffect(() => {
if (
!multiModel.isMultiModelActive &&
preMultiModelFoldedRef.current !== null
) {
setFolded(preMultiModelFoldedRef.current);
preMultiModelFoldedRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [multiModel.isMultiModelActive]);
// Sync single-model selection to llmManager so the submission path uses
// the correct provider/version. Guard against echoing derived state back
// — only call updateCurrentLlm when the selection actually differs from
// currentLlm, otherwise the initial [] → [currentLlmModel] sync would
// pin `userHasManuallyOverriddenLLM=true` with whatever was resolved
// first (often the default model before the session's alt_model loads).
useEffect(() => {
if (multiModel.selectedModels.length === 1) {
const model = multiModel.selectedModels[0]!;
const current = llmManager.currentLlm;
if (
model.provider !== current.provider ||
model.modelName !== current.modelName ||
model.name !== current.name ||
(model.modelConfigurationId ?? null) !==
(current.modelConfigurationId ?? null)
) {
llmManager.updateCurrentLlm({
name: model.name,
provider: model.provider,
modelName: model.modelName,
modelConfigurationId: model.modelConfigurationId,
});
}
}
}, [multiModel.selectedModels]);
const {
onSubmit,
stopGenerating,
handleMessageSpecificFileUpload,
availableContextTokens,
} = useChatController({
llmManager,
toolConfiguration,
availableAgents: agents,
activeAgent,
existingChatSessionId: currentChatSessionId,
selectedDocuments,
searchParams,
resetInputBar,
});
const { onMessageSelection, sessionFetchError } = useChatSessionController({
existingChatSessionId: currentChatSessionId,
searchParams,
firstMessage,
setSelectedDocuments,
setCurrentMessageFiles,
chatSessionIdRef,
loadedIdSessionRef,
chatInputBarRef,
isInitialLoad,
submitOnLoadPerformed,
refreshChatSessions,
onSubmit,
});
// A link can arrive carrying both a prompt and a search scope. Declared here
// because it submits, so it needs `onSubmit` above it.
useSendChatMessageFromURL({
onSubmit,
deepResearch: deepResearchEnabledForCurrentWorkflow,
toolConfiguration,
});
useSendMessageToParent();
const retrievalEnabled = useChatSessionSupportsRetrieval();
// Close the sources panel once it has nothing left to show. The panel is not
// rendered for an agent that cannot retrieve, so this clears a visible flag
// left behind by the previous agent.
useEffect(() => {
// Already closed.
if (!documentSidebarVisible) return;
// Retrieval is not known yet. Closing now would need a reopen later.
if (retrievalEnabled === null) return;
// Not reading a conversation, so there are no sources to show.
if (!appPosition.isChattable()) {
updateCurrentDocumentSidebarVisible(false);
return;
}
// The agent can retrieve, so it can still cite sources.
if (retrievalEnabled) return;
// The user picked documents by hand.
if (selectedDocuments.length > 0) return;
updateCurrentDocumentSidebarVisible(false);
}, [
documentSidebarVisible,
appPosition,
retrievalEnabled,
selectedDocuments,
updateCurrentDocumentSidebarVisible,
]);
const handleResubmitLastMessage = useCallback(() => {
// Grab the last user-type message
const lastUserMsg = messageHistory
.slice()
.reverse()
.find((m) => m.type === "user");
if (!lastUserMsg) {
toast.error(t("noPreviousMessage.toast"));
return;
}
// We call onSubmit, passing a `messageOverride`
onSubmit({
message: lastUserMsg.message,
currentMessageFiles: currentMessageFiles,
deepResearch:
deepResearchEnabledForCurrentWorkflow && !multiModel.isMultiModelActive,
messageIdToResend: lastUserMsg.messageId,
});
}, [
messageHistory,
onSubmit,
currentMessageFiles,
deepResearchEnabledForCurrentWorkflow,
multiModel.isMultiModelActive,
t,
]);
if (resolvedUser === null) {
// Carries the current URL like the layout's redirect does, since both run
// on the same server render and either may reach the browser first.
const query = searchParams?.toString();
redirect(loginPath({ next: query ? `${pathname}?${query}` : pathname }));
}
const onChat = useCallback(
(message: string) => {
if (multiModel.isMultiModelActive) {
foldSidebarForMultiModel();
}
resetInputBar();
onSubmit({
message,
currentMessageFiles,
deepResearch:
deepResearchEnabledForCurrentWorkflow &&
!multiModel.isMultiModelActive,
selectedModels: multiModel.isMultiModelActive
? multiModel.selectedModels
: undefined,
});
if (showOnboarding || !onboardingDismissed) {
finishOnboarding();
}
},
[
resetInputBar,
onSubmit,
currentMessageFiles,
deepResearchEnabledForCurrentWorkflow,
multiModel.isMultiModelActive,
multiModel.selectedModels,
foldSidebarForMultiModel,
showOnboarding,
onboardingDismissed,
finishOnboarding,
]
);
const { submit: submitQuery, state, setAppMode } = useQueryController();
const defaultAppMode =
(user?.preferences?.default_app_mode?.toLowerCase() as "chat" | "search") ??
"chat";
const isNewSession = appPosition.isNewSession();
const isSearch =
state.phase === "searching" || state.phase === "search-results";
// 1. Reset the app-mode back to the user's default when navigating back to the "New Sessions" tab.
// 2. If we're navigating away from the "New Session" tab after performing a search, we reset the app-input-bar.
useEffect(() => {
if (isNewSession) setAppMode(defaultAppMode);
if (!isNewSession && isSearch) resetInputBar();
}, [isNewSession, defaultAppMode, isSearch, resetInputBar, setAppMode]);
// Declared after the default-mode reset so incognito wins the same commit:
// search mode has its own persistence and no incognito safeguards.
useEffect(() => {
if (incognitoEnabled) setAppMode("chat");
}, [incognitoEnabled, setAppMode]);
const handleSearchDocumentClick = useCallback(
(doc: MinimalOnyxDocument) => setPresentingDocument(doc),
[]
);
const handleAppInputBarSubmit = useCallback(
async (message: string) => {
// If we're in an existing chat session, always use chat mode
// (appMode only applies to new sessions)
if (currentChatSessionId) {
resetInputBar();
onSubmit({
message,
currentMessageFiles,
deepResearch:
deepResearchEnabledForCurrentWorkflow &&
!multiModel.isMultiModelActive,
selectedModels: multiModel.isMultiModelActive
? multiModel.selectedModels
: undefined,
});
if (showOnboarding || !onboardingDismissed) {
finishOnboarding();
}
return;
}
// Incognito always routes to chat: the search path runs its own
// persistence and none of the incognito safeguards.
if (incognitoEnabledRef.current) {
onChat(message);
return;
}
// For new sessions, let the query controller handle routing.
// resetInputBar is called inside onChat for chat-routed queries.
// For search-routed queries, the input bar is intentionally kept
// so the user can see and refine their search query.
await submitQuery(message, onChat);
},
[
currentChatSessionId,
submitQuery,
onChat,
incognitoEnabledRef,
resetInputBar,
onSubmit,
currentMessageFiles,
deepResearchEnabledForCurrentWorkflow,
showOnboarding,
onboardingDismissed,
finishOnboarding,
multiModel.isMultiModelActive,
multiModel.selectedModels,
]
);
// Memoized callbacks for DocumentsSidebar
const handleMobileDocumentSidebarClose = useCallback(() => {
updateCurrentDocumentSidebarVisible(false);
}, [updateCurrentDocumentSidebarVisible]);
const handleDesktopDocumentSidebarClose = useCallback(() => {
setTimeout(() => updateCurrentDocumentSidebarVisible(false), 300);
}, [updateCurrentDocumentSidebarVisible]);
// When no chat session exists but a project is selected, fetch the
// total tokens for the project's files so upload UX can compare
// against available context similar to session-based flows.
const [projectContextTokenCount, setProjectContextTokenCount] = useState(0);
// Fetch project-level token count when no chat session exists.
// Note: useEffect cannot be async, so we define an inner async function (run)
// and invoke it. The `cancelled` guard prevents setting state after the
// component unmounts or when the dependencies change and a newer effect run
// supersedes an older in-flight request.
useEffect(() => {
let cancelled = false;
async function run() {
if (!currentChatSessionId && currentProjectId !== null) {
try {
const total = await getProjectTokenCount(currentProjectId);
if (!cancelled) setProjectContextTokenCount(total || 0);
} catch {
if (!cancelled) setProjectContextTokenCount(0);
}
} else {
setProjectContextTokenCount(0);
}
}
run();
return () => {
cancelled = true;
};
}, [currentChatSessionId, currentProjectId, currentProjectDetails?.files]);
// Handle error case where no agents are available.
// Only show this after agents have loaded to prevent flash during initial load.
if (!activeAgent && !isLoadingAgents) {
return <NoAgentModal />;
}
const hasAgentStarterMessages =
(activeAgent?.starter_messages?.length ?? 0) > 0;
const isWelcomeFocus =
(appPosition.isNewSession() || appPosition.isAgent()) &&
(state.phase === "idle" || state.phase === "classifying");
const onboardingVisible =
isWelcomeFocus &&
(showOnboarding || !user?.personalization?.name) &&
!onboardingDismissed;
const gridStyle = {
// minmax(0, 1fr) (instead of "1fr") lets the single column shrink to the
// grid's width. A bare "1fr" is minmax(auto, 1fr), whose auto minimum is
// the content's min-content — wide content (e.g. the onboarding cards) would
// otherwise blow the column past the viewport and clip the right edge.
gridTemplateColumns: "minmax(0, 1fr)",
// Onboarding: welcome floored at content height, form row compressible
// (scrolls), bottom row absorbs slack. Centered when short, pinned when tall.
gridTemplateRows: onboardingVisible
? "minmax(min-content, 1fr) minmax(0, max-content) minmax(0, 1fr)"
: isSearch
? "0fr auto 1fr"
: appPosition.isChat()
? "1fr auto 0fr"
: appPosition.isProject()
? "auto auto 1fr"
: "1fr auto 1fr",
};
if (!isReady) return <OnyxInitializingLoader />;
return (
<>
<AppPopup />
{presentingDocument && (
<PreviewModal
presentingDocument={presentingDocument}
onClose={() => setPresentingDocument(null)}
/>
)}
<FederatedOAuthModal />
{retrievalEnabled &&
(isMobile ? (
documentSidebarVisible && (
<Modal
open
onOpenChange={() => updateCurrentDocumentSidebarVisible(false)}
>
<Modal.Content>
<Modal.Header
icon={SvgFileText}
title={t("sourcesModal.title")}
onClose={() => updateCurrentDocumentSidebarVisible(false)}
/>
<Modal.Body>
{/* IMPORTANT: this is a memoized component, and it's very important
for performance reasons that this stays true. MAKE SURE that all function
props are wrapped in useCallback. */}
<DocumentsSidebar
setPresentingDocument={setPresentingDocument}
modal
closeSidebar={handleMobileDocumentSidebarClose}
selectedDocuments={selectedDocuments}
/>
</Modal.Body>
</Modal.Content>
</Modal>
)
) : (
<RootLayout.RightPanel>
<div
className={cn(
"overflow-hidden transition-all duration-300 ease-in-out h-full",
documentSidebarVisible ? "w-100" : "w-0"
)}
>
<DocumentsSidebar
setPresentingDocument={setPresentingDocument}
modal={false}
closeSidebar={handleDesktopDocumentSidebarClose}
selectedDocuments={selectedDocuments}
/>
</div>
</RootLayout.RightPanel>
))}
<div className="w-full h-full overflow-hidden">
{/* noPaste: the input bar already uploads pasted files itself. Without
it react-dropzone handles the same paste again and attaches the
image twice. */}
<Dropzone
onDrop={(acceptedFiles) =>
handleMessageSpecificFileUpload(acceptedFiles)
}
noClick
noPaste
>
{({ getRootProps }) => (
<div
className="h-full w-full flex flex-col items-center outline-hidden relative"
{...getRootProps({ tabIndex: -1 })}
>
{/* Main content grid — 3 rows, animated */}
<div
className="flex-1 w-full grid min-h-0 transition-[grid-template-rows] duration-150 ease-in-out"
style={gridStyle}
>
{/* ── Top row: ChatUI / WelcomeMessage / ProjectUI ── */}
{/* No horizontal padding: the scroll container reaches the edge so
its scrollbar sits flush; non-chat siblings add their own px. */}
<div className="row-start-1 min-h-0 overflow-hidden flex flex-col items-center">
{/* ChatUI */}
<Fade
show={
appPosition.isChat() &&
!!currentChatSessionId &&
!!activeAgent &&
!sessionFetchError
}
className="h-full w-full flex flex-col items-center"
>
<ChatScrollContainer
ref={scrollContainerRef}
sessionId={currentChatSessionId!}
anchorSelector={anchorSelector}
autoScroll={autoScrollEnabled}
isStreaming={isStreaming}
onScrollButtonVisibilityChange={setShowScrollButton}
fullWidth={fullWidthActive}
>
<ChatUI
activeAgent={activeAgent!}
llmManager={llmManager}
deepResearchEnabled={
deepResearchEnabledForCurrentWorkflow
}
currentMessageFiles={currentMessageFiles}
setPresentingDocument={setPresentingDocument}
onSubmit={onSubmit}
onMessageSelection={onMessageSelection}
stopGenerating={stopGenerating}
onResubmit={handleResubmitLastMessage}
anchorNodeId={anchorNodeId}
selectedModels={multiModel.selectedModels}
fullWidthChat={fullWidthActive}
/>
</ChatScrollContainer>
</Fade>
{/* Session fetch error (404 / 403) */}
<Fade
show={appPosition.isChat() && sessionFetchError !== null}
className="h-full w-full flex flex-col items-center justify-center px-2 sm:px-4"
>
{sessionFetchError && (
<Section
flexDirection="column"
alignItems="center"
gap={4}
>
<IllustrationContent
illustration={
sessionFetchError.type === "access_denied"
? SvgNoAccess
: SvgNotFound
}
title={
sessionFetchError.type === "not_found"
? t("sessionNotFound.title")
: sessionFetchError.type === "access_denied"
? t("sessionAccessDenied.title")
: t("sessionGenericError.title")
}
description={
sessionFetchError.type === "not_found"
? t("sessionNotFound.description")
: sessionFetchError.type === "access_denied"
? t("sessionAccessDenied.description")
: sessionFetchError.detail
}
/>
<Button href="/app" prominence="secondary">
{t("newChatButton.label")}
</Button>
</Section>
)}
</Fade>
{/* ProjectUI */}
{appPosition.isProject() && (
<div className="w-full max-h-[50vh] overflow-y-auto overscroll-y-none px-2 sm:px-4">
<ProjectContextPanel
projectTokenCount={projectContextTokenCount}
availableContextTokens={availableContextTokens}
setPresentingDocument={setPresentingDocument}
/>
</div>
)}
{/* WelcomeMessageUI */}
<Fade
show={isWelcomeFocus}
className="w-full flex-1 flex flex-col items-center justify-end px-2 sm:px-4"
>
<Section
flexDirection="row"
justifyContent="between"
alignItems="end"
className={cn(
!fullWidthActive &&
"max-w-(--app-page-main-content-width)"
)}
>
<WelcomeMessage
agent={activeAgent}
isDefaultAgent={isPlainChat}
/>
{!isSearch &&
!(
state.phase === "idle" && state.appMode === "search"
) &&
activeAgent &&
llmManager.hasAnyProvider && (
<MultiModelSelector
selectedModels={multiModel.selectedModels}
onAdd={multiModel.addModel}
onRemove={multiModel.removeModel}
onReplace={multiModel.replaceModel}
temperatureManager={llmManager}
reasoningManager={llmManager}
/>
)}
</Section>
<Spacer rem={1.5} />
</Fade>
</div>
{/* ── Middle-center: AppInputBar ── */}
<div
className={cn(
"row-start-2 flex flex-col items-center px-2 sm:px-4",
onboardingVisible && "min-h-0",
sessionFetchError && "hidden"
)}
>
<div
className={cn(
"relative w-full flex flex-col",
onboardingVisible && "min-h-0",
!fullWidthActive &&
"md:max-w-(--app-page-main-content-width)"
)}
>
{/* Scroll to bottom button - positioned absolutely above AppInputBar */}
{appPosition.isChat() && showScrollButton && (
<div className="absolute -top-14 self-center">
<Button
icon={SvgChevronDown}
onClick={handleScrollToBottom}
aria-label={t("scrollToBottomButton.label")}
prominence="secondary"
/>
</div>
)}
{/* OnboardingUI */}
{onboardingVisible && (
<ShadowDiv variant="mask" className="overscroll-contain">
<OnboardingFlow
showOnboarding={showOnboarding}
handleHideOnboarding={hideOnboarding}
handleFinishOnboarding={finishOnboarding}
state={onboardingState}
actions={onboardingActions}
/>
</ShadowDiv>
)}
{/*
# Note (@raunakab)
`shadow-box-01` on AppInputBar extends ~14px below the element
(2px offset + 12px blur). Because the content area in
`RootLayout` (@opal/layouts) uses `overflow-auto`, shadows
that exceed the container bounds are clipped.
The animated spacer divs above and below the AppInputBar
provide 14px of breathing room so the shadow renders fully.
They transition between h-0 and h-[14px] depending on whether
the classification is "search" (spacer above) or "chat"
(spacer below).
There is a corresponding note inside the Footer in
`AppChrome.tsx` that explains why the Footer removes its
top padding during chat to compensate for this extra space.
*/}
<div className={cn(onboardingVisible && "shrink-0 pt-6")}>
<div
className={cn(
"transition-all duration-150 ease-in-out overflow-hidden",
isSearch ? "h-[14px]" : "h-0"
)}
/>
{appPosition.isChat() && activeAgent && (
<div className="pb-1">
<MultiModelSelector
selectedModels={multiModel.selectedModels}
onAdd={multiModel.addModel}
onRemove={multiModel.removeModel}
onReplace={multiModel.replaceModel}
temperatureManager={llmManager}
reasoningManager={llmManager}
/>
</div>
)}
<AppInputBar
toolConfiguration={toolConfiguration}
ref={chatInputBarRef}
deepResearchEnabled={
deepResearchEnabledForCurrentWorkflow
}
toggleDeepResearch={toggleDeepResearch}
isMultiModelActive={multiModel.isMultiModelActive}
llmManager={llmManager}
initialMessage={
searchParams?.get(SEARCH_PARAM_NAMES.USER_PROMPT) ||
""
}
stopGenerating={stopGenerating}
onSubmit={handleAppInputBarSubmit}
chatState={currentChatState}
activeAgent={activeAgent}
handleFileUpload={handleMessageSpecificFileUpload}
setPresentingDocument={setPresentingDocument}
// Intentionally enabled during name-only onboarding (showOnboarding=false)
// since LLM providers are already configured and the user can chat.
disabled={
(!llmManager.isLoadingProviders &&
llmManager.hasAnyProvider === false) ||
(showOnboarding &&
!isLoadingOnboarding &&
onboardingState.currentStep !==
OnboardingStep.Complete)
}
/>
<div
className={cn(
"transition-all duration-150 ease-in-out overflow-hidden",
appPosition.isChat() ? "h-[14px]" : "h-0"
)}
/>
</div>
</div>
</div>
{/* ── Bottom: SearchResults + SourceFilter / Suggestions / ProjectChatList ── */}
<div className="row-start-3 min-h-0 overflow-hidden flex flex-col items-center w-full px-2 sm:px-4">
{/* Agent description below input */}
{(appPosition.isNewSession() || appPosition.isAgent()) &&
!isPlainChat && (
<>
<Spacer rem={1} />
<AgentDescription agent={activeAgent} />
<Spacer rem={1.5} />
</>
)}
{/* ProjectChatSessionList */}
{appPosition.isProject() && (
<div className="w-full max-w-(--app-page-main-content-width) h-full overflow-y-auto overscroll-y-none mx-auto">
<ProjectChatSessionList />
</div>
)}
{/* SuggestionsUI */}
<Fade
show={
(appPosition.isNewSession() || appPosition.isAgent()) &&
hasAgentStarterMessages
}
className="h-full flex-1 w-full max-w-(--app-page-main-content-width)"
>
<Spacer rem={0.5} />
<Suggestions onSubmit={onSubmit} />
</Fade>
{/* SearchUI */}
<Fade
show={isSearch}
className="h-full flex-1 w-full max-w-(--app-page-main-content-width) px-1 flex flex-col"
>
<Spacer rem={0.75} />
<SearchUI onDocumentClick={handleSearchDocumentClick} />
</Fade>
</div>
</div>
</div>
)}
</Dropzone>
</div>
</>
);
}