"use client"; import { redirect, useRouter, useSearchParams } from "next/navigation"; import { endIncognitoSession, personaIncludesRetrieval, } 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, useFilters, useLlmManager } from "@/lib/hooks"; import { useForcedTools } from "@/lib/hooks/useForcedTools"; import OnyxInitializingLoader from "@/components/OnyxInitializingLoader"; import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/interfaces"; 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 useTags from "@/hooks/useTags"; 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 "@/sections/modals/NoAgentModal"; import PreviewModal from "@/sections/modals/PreviewModal"; import { Modal } from "@opal/components"; import { useSendMessageToParent } from "@/lib/extension/hooks"; import { SUBMIT_MESSAGE_TYPES } from "@/lib/extension/constants"; import { getSourceMetadata } from "@/lib/sources"; import { SourceMetadata } from "@/lib/search/interfaces"; import { FederatedConnectorDetail, ValidSources } from "@/lib/types"; 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 { useAgentController } from "@/lib/agents/hooks"; import useChatSessionController from "@/hooks/useChatSessionController"; import useDeepResearchToggle from "@/hooks/useDeepResearchToggle"; import { useIncognito } from "@/providers/IncognitoProvider"; import { useIsDefaultAgent } from "@/lib/agents/hooks"; 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 from "@/sections/projects/ProjectContextPanel"; import { useProjectsContext } from "@/providers/ProjectsContext"; import { useActiveProject, useProjects } from "@/lib/projects/hooks"; import { getProjectTokenCount } from "@/lib/projects/svc"; import ProjectChatSessionList from "@/sections/projects/ProjectChatSessionList"; 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 useAppFocus from "@/hooks/useAppFocus"; 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"; interface FadeProps { show: boolean; children?: React.ReactNode; className?: string; } function Fade({ show, children, className }: FadeProps) { return ( {show && ( {children} )} ); } 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 router = useRouter(); const appFocus = useAppFocus(); const { isMobile } = useScreenSize(); useToastFromQuery({ oauth_connected: { message: "Authentication successful", type: "success", }, }); const searchParams = useSearchParams(); // Use SWR hooks for data fetching const { chatSessions, refreshChatSessions, currentChatSession, currentChatSessionId, isLoading: isLoadingChatSessions, } = useChatSessions(); const { vectorDbEnabled, disable_default_assistant } = useSettings(); const { ccPairs } = useCCPairs(vectorDbEnabled); const { tags } = useTags(); const { documentSets } = useDocumentSets(); const { currentMessageFiles, setCurrentMessageFiles, currentProjectId, currentProjectDetails, lastFailedFiles, clearLastFailedFiles, } = useProjectsContext(); // When changing from project chat to main chat (or vice-versa), clear forced tools const { setForcedToolIds } = useForcedTools(); useEffect(() => { setForcedToolIds([]); }, [currentProjectId, setForcedToolIds]); const isInitialLoad = useRef(true); const { agents, isLoading: isLoadingAgents } = useAgents(); // Also fetch federated connectors for the sources list const { data: federatedConnectorsData } = useFederatedConnectors(); 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(); function processSearchParamsAndSubmitMessage(searchParamsString: string) { const newSearchParams = new URLSearchParams(searchParamsString); const message = newSearchParams?.get("user-prompt"); filterManager.buildFiltersFromQueryString( newSearchParams.toString(), sources, documentSets.map((ds) => ds.name), tags ); newSearchParams.delete(SEARCH_PARAM_NAMES.SEND_ON_LOAD); router.replace(`?${newSearchParams.toString()}`, { scroll: false }); // If there's a message, submit it if (message) { onSubmit({ message, currentMessageFiles, deepResearch: deepResearchEnabledForCurrentWorkflow, }); } } const { selectedAgent, setSelectedAgentFromId, liveAgent } = useAgentController(currentChatSession, () => { // Only remove project context if user explicitly selected an agent // (i.e., agentId is present). Avoid clearing project when agentId was removed. const newSearchParams = new URLSearchParams( searchParams?.toString() || "" ); if (newSearchParams.has(SEARCH_PARAM_NAMES.PERSONA_ID)) { newSearchParams.delete(SEARCH_PARAM_NAMES.PROJECT_ID); router.replace(`?${newSearchParams.toString()}`, { scroll: false }); } }); const { deepResearchEnabled, toggleDeepResearch } = useDeepResearchToggle({ chatSessionId: currentChatSessionId, agentId: selectedAgent?.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(null); const llmManager = useLlmManager(currentChatSession ?? undefined, liveAgent); const { showOnboarding, onboardingDismissed, onboardingState, onboardingActions, isLoadingOnboarding, finishOnboarding, hideOnboarding, } = useShowOnboarding({ liveAgent, isLoadingChatSessions, chatSessionsCount: chatSessions.length, userId: user?.id, }); const noAgents = liveAgent === null || liveAgent === undefined; const availableSources: ValidSources[] = useMemo(() => { return ccPairs.map((ccPair) => ccPair.source); }, [ccPairs]); const sources: SourceMetadata[] = useMemo(() => { const uniqueSources = Array.from(new Set(availableSources)); const regularSources = uniqueSources.map((source) => getSourceMetadata(source) ); // Add federated connectors as sources const federatedSources = federatedConnectorsData?.map((connector: FederatedConnectorDetail) => { return getSourceMetadata(connector.source); }) || []; // Combine sources and deduplicate based on internalName const allSources = [...regularSources, ...federatedSources]; const deduplicatedSources = allSources.reduce((acc, source) => { const existing = acc.find((s) => s.internalName === source.internalName); if (!existing) { acc.push(source); } return acc; }, [] as SourceMetadata[]); return deduplicatedSources; }, [availableSources, federatedConnectorsData]); // 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( lastFailedFiles.length === 1 ? `File failed and was removed: ${names}` : `Files failed and were removed: ${names}` ); clearLastFailedFiles(); } }, [lastFailedFiles, clearLastFailedFiles]); const chatInputBarRef = useRef(null); const filterManager = useFilters(); const isDefaultAgent = useIsDefaultAgent( liveAgent, currentChatSessionId, currentChatSession ?? undefined, disable_default_assistant ?? false ); const scrollContainerRef = useRef(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(currentChatSessionId); const loadedIdSessionRef = useRef(currentChatSessionId); const submitOnLoadPerformed = useRef(false); function loadNewPageLogic(event: MessageEvent) { if (event.data.type === SUBMIT_MESSAGE_TYPES.PAGE_CHANGE) { try { const url = new URL(event.data.href); processSearchParamsAndSubmitMessage(url.searchParams.toString()); } catch (error) { console.error("Error parsing URL:", error); } } } // Equivalent to `loadNewPageLogic` useEffect(() => { if (searchParams?.get(SEARCH_PARAM_NAMES.SEND_ON_LOAD)) { processSearchParamsAndSubmitMessage(searchParams.toString()); } }, [searchParams, router]); useEffect(() => { window.addEventListener("message", loadNewPageLogic); return () => { window.removeEventListener("message", loadNewPageLogic); }; }, []); const [selectedDocuments, setSelectedDocuments] = useState( [] ); // 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]); // Block input when the last turn is multi-model and the user hasn't // selected a preferred response yet. Without a selection, it's ambiguous // which model's response should be used as context for the next message. const awaitingPreferredSelection = useMemo(() => { if (!messageTree || currentChatState !== "input") return false; // Find the last user message in the history const lastUserMsg = [...messageHistory] .reverse() .find((m) => m.type === "user"); if (!lastUserMsg) return false; const childIds = lastUserMsg.childrenNodeIds ?? []; if (childIds.length < 2) return false; // Check if children are multi-model (have modelDisplayName) const multiModelChildren = childIds .map((id) => messageTree.get(id)) .filter( (m) => m && (m.type === "assistant" || m.type === "error") && (m.modelDisplayName || m.overridden_model) ); if (multiModelChildren.length < 2) return false; // Check if a preferred response has been set on this user message return lastUserMsg.preferredResponseId == null; }, [messageHistory, messageTree, currentChatState]); // 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 && ((appFocus.isChat() && !!currentChatSessionId) || appFocus.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(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({ filterManager, llmManager, availableAgents: agents, liveAgent, existingChatSessionId: currentChatSessionId, selectedDocuments, searchParams, resetInputBar, setSelectedAgentFromId, }); const { onMessageSelection, currentSessionFileTokenCount, sessionFetchError, } = useChatSessionController({ existingChatSessionId: currentChatSessionId, searchParams, filterManager, firstMessage, setSelectedAgentFromId, setSelectedDocuments, setCurrentMessageFiles, chatSessionIdRef, loadedIdSessionRef, chatInputBarRef, isInitialLoad, submitOnLoadPerformed, refreshChatSessions, onSubmit, }); useSendMessageToParent(); const retrievalEnabled = useMemo(() => { if (liveAgent) { return personaIncludesRetrieval(liveAgent); } return false; }, [liveAgent]); useEffect(() => { if ( (!personaIncludesRetrieval && (!selectedDocuments || selectedDocuments.length === 0) && documentSidebarVisible) || !currentChatSessionId ) { updateCurrentDocumentSidebarVisible(false); } }, [currentChatSessionId]); const handleResubmitLastMessage = useCallback(() => { // Grab the last user-type message const lastUserMsg = messageHistory .slice() .reverse() .find((m) => m.type === "user"); if (!lastUserMsg) { toast.error("No previously-submitted user message found."); 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, ]); if (resolvedUser === null) { redirect("/auth/login"); } 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 = appFocus.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 assistants are available // Only show this after agents have loaded to prevent flash during initial load if (noAgents && !isLoadingAgents) { return ; } const hasAgentStarterMessages = (liveAgent?.starter_messages?.length ?? 0) > 0; const isWelcomeFocus = (appFocus.isNewSession() || appFocus.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" : appFocus.isChat() ? "1fr auto 0fr" : appFocus.isProject() ? "auto auto 1fr" : "1fr auto 1fr", }; if (!isReady) return ; return ( <> {retrievalEnabled && documentSidebarVisible && isMobile && ( updateCurrentDocumentSidebarVisible(false)} > updateCurrentDocumentSidebarVisible(false)} /> {/* 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. */} )} {presentingDocument && ( setPresentingDocument(null)} /> )} {!(noAgents && !isLoadingAgents) && retrievalEnabled && !isMobile && (
)}
handleMessageSpecificFileUpload(acceptedFiles) } noClick > {({ getRootProps }) => (
{/* Main content grid — 3 rows, animated */}
{/* ── 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. */}
{/* ChatUI */} {/* Session fetch error (404 / 403) */} {sessionFetchError && (
)}
{/* ProjectUI */} {appFocus.isProject() && (
)} {/* WelcomeMessageUI */}
{!isSearch && !( state.phase === "idle" && state.appMode === "search" ) && liveAgent && llmManager.hasAnyProvider && ( )}
{/* ── Middle-center: AppInputBar ── */}
{/* Scroll to bottom button - positioned absolutely above AppInputBar */} {appFocus.isChat() && showScrollButton && (
)} {/* OnboardingUI */} {onboardingVisible && ( )} {/* # 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. */}
{appFocus.isChat() && liveAgent && (
)}
{/* ── Bottom: SearchResults + SourceFilter / Suggestions / ProjectChatList ── */}
{/* Agent description below input */} {(appFocus.isNewSession() || appFocus.isAgent()) && !isDefaultAgent && ( <> )} {/* ProjectChatSessionList */} {appFocus.isProject() && (
)} {/* SuggestionsUI */} {/* SearchUI */}
)}
); }