import { AppEvents } from '../constants/events'; import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { defineMessages, useIntl } from '../i18n'; import { useLocation, useNavigate } from 'react-router-dom'; import { SearchView } from './conversation/SearchView'; import LoadingGoose from './LoadingGoose'; import ProgressiveMessageList from './ProgressiveMessageList'; import { MainPanelLayout } from './Layout/MainPanelLayout'; import ChatInput from './ChatInput'; import { ChatInputCard } from './ChatInputCard'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; import { useFileDrop } from '../hooks/useFileDrop'; import { Message } from '../api'; import { ChatState } from '../types/chatState'; import { ChatType } from '../types/chat'; import { useIsMobile } from '../hooks/use-mobile'; import { useNavigationContextSafe } from './Layout/NavigationContext'; import { cn } from '../utils'; import { useChatStream } from '../hooks/useChatStream'; import { useNavigation } from '../hooks/useNavigation'; import { RecipeHeader } from './RecipeHeader'; import { RecipeWarningModal } from './ui/RecipeWarningModal'; import { scanRecipe } from '../recipe'; import { UserInput } from '../types/message'; import RecipeActivities from './recipes/RecipeActivities'; import { useToolCount } from './alerts/useToolCount'; import { getThinkingMessage, getTextAndImageContent } from '../types/message'; import ParameterInputModal from './ParameterInputModal'; import { substituteParameters } from '../utils/parameterSubstitution'; import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal'; import { toastSuccess } from '../toasts'; import { Recipe } from '../recipe'; import { useAutoSubmit } from '../hooks/useAutoSubmit'; import { Goose } from './icons'; import EnvironmentBadge from './GooseSidebar/EnvironmentBadge'; const i18n = defineMessages({ failedToLoadSession: { id: 'baseChat.failedToLoadSession', defaultMessage: 'Failed to Load Session', }, goHome: { id: 'baseChat.goHome', defaultMessage: 'Go home', }, noSession: { id: 'baseChat.noSession', defaultMessage: 'No Session', }, recipeCreatedTitle: { id: 'baseChat.recipeCreatedTitle', defaultMessage: 'Recipe created successfully!', }, recipeCreatedMessage: { id: 'baseChat.recipeCreatedMessage', defaultMessage: '"{title}" has been saved and is ready to use.', }, }); interface BaseChatProps { setChat: (chat: ChatType) => void; onMessageSubmit?: (message: string) => void; renderHeader?: () => React.ReactNode; customChatInputProps?: Record; customMainLayoutProps?: Record; contentClassName?: string; disableSearch?: boolean; suppressEmptyState: boolean; sessionId: string; isActiveSession: boolean; initialMessage?: UserInput; } export default function BaseChat({ setChat, renderHeader, customChatInputProps = {}, customMainLayoutProps = {}, sessionId, initialMessage, isActiveSession, }: BaseChatProps) { const intl = useIntl(); const location = useLocation(); const navigate = useNavigate(); const scrollRef = useRef(null); const chatInputRef = useRef(null); const disableAnimation = location.state?.disableAnimation || false; const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false); const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState(); const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false); const isMobile = useIsMobile(); const navContext = useNavigationContextSafe(); const setView = useNavigation(); const isNavCollapsed = !navContext?.isNavExpanded; const contentClassName = cn('pr-1 pb-10 pt-10', (isMobile || isNavCollapsed) && 'pt-14'); const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop(); const onStreamFinish = useCallback(() => {}, []); const [isCreateRecipeModalOpen, setIsCreateRecipeModalOpen] = useState(false); const { session, messages, chatState, setChatState, handleSubmit, submitElicitationResponse, stopStreaming, sessionLoadError, setRecipeUserParams, tokenState, notifications: toolCallNotifications, onMessageUpdate, } = useChatStream({ sessionId, onStreamFinish, }); const recipe = session?.recipe; const resolvedInitialMessage = useMemo((): UserInput | undefined => { if (!initialMessage) return undefined; if (recipe?.prompt && session?.user_recipe_values) { return { ...initialMessage, msg: substituteParameters(initialMessage.msg, session.user_recipe_values), }; } return initialMessage; }, [initialMessage, recipe?.prompt, session?.user_recipe_values]); const canAutoSubmit = session?.session_type === 'scheduled' || !recipe || hasNotAcceptedRecipe === false; useAutoSubmit({ sessionId, session, messages, chatState, initialMessage: resolvedInitialMessage, canAutoSubmit, handleSubmit, }); useEffect(() => { let streamState: 'idle' | 'loading' | 'streaming' | 'error' = 'idle'; if (chatState === ChatState.LoadingConversation) { streamState = 'loading'; } else if ( chatState === ChatState.Streaming || chatState === ChatState.Thinking || chatState === ChatState.Compacting ) { streamState = 'streaming'; } else if (sessionLoadError) { streamState = 'error'; } window.dispatchEvent( new CustomEvent(AppEvents.SESSION_STATUS_UPDATE, { detail: { sessionId, streamState, messageCount: messages.length, }, }) ); }, [sessionId, chatState, messages.length, sessionLoadError]); // Generate command history from user messages (most recent first) const commandHistory = useMemo(() => { return messages .reduce((history, message) => { if (message.role === 'user') { const text = getTextAndImageContent(message).textContent.trim(); if (text) { history.push(text); } } return history; }, []) .reverse(); }, [messages]); const chatInputSubmit = (input: UserInput) => { if (recipe && input.msg.trim()) { setHasStartedUsingRecipe(true); } handleSubmit(input); }; const sessionModel = session?.model_config?.model_name ?? null; const sessionProvider = session?.provider_name ?? null; const sessionLoaded = session !== undefined; const latestInference = useMemo(() => { for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (message.role === 'assistant' && message.metadata.userVisible && message.metadata.inference) { return message.metadata.inference; } } return null; }, [messages]); useEffect(() => { if (!recipe || !isActiveSession || session?.session_type === 'scheduled') return; (async () => { const accepted = await window.electron.hasAcceptedRecipeBefore(recipe); setHasNotAcceptedRecipe(!accepted); if (!accepted) { const scanResult = await scanRecipe(recipe); setHasRecipeSecurityWarnings(scanResult.has_security_warnings); } })(); }, [recipe, isActiveSession, session?.session_type]); const handleRecipeAccept = async (accept: boolean) => { if (recipe && accept) { await window.electron.recordRecipeHash(recipe); setHasNotAcceptedRecipe(false); } else { setView('chat'); } }; // Track if this is the initial render for session resuming const initialRenderRef = useRef(true); // Auto-scroll when messages are loaded (for session resuming) const handleRenderingComplete = React.useCallback(() => { // Only force scroll on the very first render if (initialRenderRef.current && messages.length > 0) { initialRenderRef.current = false; if (scrollRef.current?.scrollToBottom) { scrollRef.current.scrollToBottom(); } } else if (scrollRef.current?.isFollowing) { if (scrollRef.current?.scrollToBottom) { scrollRef.current.scrollToBottom(); } } }, [messages.length]); const toolCount = useToolCount(sessionId); // Listen for global scroll-to-bottom requests (e.g., from MCP UI prompt actions) useEffect(() => { const handleGlobalScrollRequest = () => { // Add a small delay to ensure content has been rendered setTimeout(() => { if (scrollRef.current?.scrollToBottom) { scrollRef.current.scrollToBottom(); } }, 200); }; window.addEventListener(AppEvents.SCROLL_CHAT_TO_BOTTOM, handleGlobalScrollRequest); return () => window.removeEventListener(AppEvents.SCROLL_CHAT_TO_BOTTOM, handleGlobalScrollRequest); }, []); useEffect(() => { if ( isActiveSession && sessionId && chatInputRef.current && chatState !== ChatState.LoadingConversation ) { const timeoutId = setTimeout(() => { chatInputRef.current?.focus(); }, 100); return () => clearTimeout(timeoutId); } return undefined; }, [isActiveSession, sessionId, chatState]); useEffect(() => { const handleMakeAgent = () => { setIsCreateRecipeModalOpen(true); }; window.addEventListener('make-agent-from-chat', handleMakeAgent); return () => window.removeEventListener('make-agent-from-chat', handleMakeAgent); }, []); useEffect(() => { const handleSessionForked = (event: Event) => { const customEvent = event as CustomEvent<{ newSessionId: string; shouldStartAgent?: boolean; editedMessage?: string; }>; window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); const { newSessionId, shouldStartAgent, editedMessage } = customEvent.detail; const params = new URLSearchParams(); params.set('resumeSessionId', newSessionId); if (shouldStartAgent) { params.set('shouldStartAgent', 'true'); } navigate(`/pair?${params.toString()}`, { state: { disableAnimation: true, initialMessage: editedMessage ? { msg: editedMessage, images: [] } : undefined, }, }); }; window.addEventListener(AppEvents.SESSION_FORKED, handleSessionForked); return () => { window.removeEventListener(AppEvents.SESSION_FORKED, handleSessionForked); }; }, [location.pathname, navigate]); const handleRecipeCreated = (recipe: Recipe) => { toastSuccess({ title: intl.formatMessage(i18n.recipeCreatedTitle), msg: intl.formatMessage(i18n.recipeCreatedMessage, { title: recipe.title }), }); }; const chat: ChatType = { messages, recipe, sessionId, name: session?.name || intl.formatMessage(i18n.noSession), }; const lastSetNameRef = useRef(''); useEffect(() => { const currentSessionName = session?.name; if (currentSessionName && currentSessionName !== lastSetNameRef.current) { lastSetNameRef.current = currentSessionName; setChat({ messages, recipe, sessionId, name: currentSessionName, }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [session?.name, setChat]); // If we have a recipe prompt and user recipe values, substitute parameters let recipePrompt = ''; if (messages.length === 0 && recipe?.prompt) { recipePrompt = session?.user_recipe_values ? substituteParameters(recipe.prompt, session.user_recipe_values) : recipe.prompt; } const initialPrompt = recipePrompt; if (sessionLoadError) { return (
{renderHeader && renderHeader()}

{intl.formatMessage(i18n.failedToLoadSession)}

{sessionLoadError}

); } return (
{/* Custom header */} {renderHeader && renderHeader()} {/* Chat container with sticky recipe header */}
{/* Goose watermark - top right */} {recipe?.title && (
)} {recipe && (
handleSubmit({ msg: text, images: [] })} activities={Array.isArray(recipe.activities) ? recipe.activities : null} title={recipe.title} parameterValues={session?.user_recipe_values || {}} />
)} {messages.length > 0 || recipe ? ( <> handleSubmit({ msg: text, images: [] })} isUserMessage={(m: Message) => m.role === 'user'} isStreamingMessage={chatState !== ChatState.Idle} onRenderingComplete={handleRenderingComplete} onMessageUpdate={onMessageUpdate} submitElicitationResponse={submitElicitationResponse} />
) : null} {chatState !== ChatState.Idle && (
0 ? getThinkingMessage(messages[messages.length - 1]) : undefined } />
)}
setDroppedFiles([])} // Clear dropped files after processing messages={messages} disableAnimation={disableAnimation} recipe={recipe} recipeAccepted={!hasNotAcceptedRecipe} initialPrompt={initialPrompt} toolCount={toolCount || 0} sessionModel={sessionModel} sessionProvider={sessionProvider} sessionLoaded={sessionLoaded} latestInference={latestInference} {...customChatInputProps} /> {recipe && isActiveSession && session?.session_type !== 'scheduled' && ( handleRecipeAccept(true)} onCancel={() => handleRecipeAccept(false)} recipeDetails={{ title: recipe.title, description: recipe.description, instructions: recipe.instructions || undefined, }} hasSecurityWarnings={hasRecipeSecurityWarnings} /> )} {recipe?.parameters && recipe.parameters.length > 0 && !session?.user_recipe_values && session?.session_type !== 'scheduled' && ( setView('chat')} initialValues={ (window.appConfig?.get('recipeParameters') as Record | undefined) || undefined } /> )} setIsCreateRecipeModalOpen(false)} sessionId={chat.sessionId} onRecipeCreated={handleRecipeCreated} />
); }