527 lines
17 KiB
TypeScript
527 lines
17 KiB
TypeScript
import { AppEvents } from '../constants/events';
|
|
import React, {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import { SearchView } from './conversation/SearchView';
|
|
import LoadingGoose from './LoadingGoose';
|
|
import PopularChatTopics from './PopularChatTopics';
|
|
import ProgressiveMessageList from './ProgressiveMessageList';
|
|
import { MainPanelLayout } from './Layout/MainPanelLayout';
|
|
import ChatInput from './ChatInput';
|
|
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 { useSidebar } from './ui/sidebar';
|
|
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 { useCostTracking } from '../hooks/useCostTracking';
|
|
import RecipeActivities from './recipes/RecipeActivities';
|
|
import { useToolCount } from './alerts/useToolCount';
|
|
import { getThinkingMessage, getTextContent } from '../types/message';
|
|
import ParameterInputModal from './ParameterInputModal';
|
|
import { substituteParameters } from '../utils/providerUtils';
|
|
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
|
|
import { toastSuccess } from '../toasts';
|
|
import { Recipe } from '../recipe';
|
|
import { useAutoSubmit } from '../hooks/useAutoSubmit';
|
|
import { Goose } from './icons/Goose';
|
|
import EnvironmentBadge from './GooseSidebar/EnvironmentBadge';
|
|
|
|
const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null);
|
|
export const useCurrentModelInfo = () => useContext(CurrentModelContext);
|
|
|
|
interface BaseChatProps {
|
|
setChat: (chat: ChatType) => void;
|
|
onMessageSubmit?: (message: string) => void;
|
|
renderHeader?: () => React.ReactNode;
|
|
customChatInputProps?: Record<string, unknown>;
|
|
customMainLayoutProps?: Record<string, unknown>;
|
|
contentClassName?: string;
|
|
disableSearch?: boolean;
|
|
showPopularTopics?: boolean;
|
|
suppressEmptyState: boolean;
|
|
sessionId: string;
|
|
isActiveSession: boolean;
|
|
initialMessage?: string;
|
|
}
|
|
|
|
function BaseChatContent({
|
|
setChat,
|
|
renderHeader,
|
|
customChatInputProps = {},
|
|
customMainLayoutProps = {},
|
|
sessionId,
|
|
initialMessage,
|
|
isActiveSession,
|
|
}: BaseChatProps) {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const scrollRef = useRef<ScrollAreaHandle>(null);
|
|
const chatInputRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const disableAnimation = location.state?.disableAnimation || false;
|
|
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
|
|
const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState<boolean>();
|
|
const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false);
|
|
|
|
const isMobile = useIsMobile();
|
|
const { state: sidebarState } = useSidebar();
|
|
const setView = useNavigation();
|
|
|
|
const contentClassName = cn(
|
|
'pr-1 pb-10 pt-10',
|
|
(isMobile || sidebarState === 'collapsed') && '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,
|
|
});
|
|
|
|
useAutoSubmit({
|
|
sessionId,
|
|
session,
|
|
messages,
|
|
chatState,
|
|
initialMessage,
|
|
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<string[]>((history, message) => {
|
|
if (message.role === 'user') {
|
|
const text = getTextContent(message).trim();
|
|
if (text) {
|
|
history.push(text);
|
|
}
|
|
}
|
|
return history;
|
|
}, [])
|
|
.reverse();
|
|
}, [messages]);
|
|
|
|
const handleFormSubmit = (e: React.FormEvent) => {
|
|
const customEvent = e as unknown as CustomEvent;
|
|
const textValue = customEvent.detail?.value || '';
|
|
|
|
if (recipe && textValue.trim()) {
|
|
setHasStartedUsingRecipe(true);
|
|
}
|
|
handleSubmit(textValue);
|
|
};
|
|
|
|
const { sessionCosts } = useCostTracking({
|
|
sessionInputTokens: session?.accumulated_input_tokens || 0,
|
|
sessionOutputTokens: session?.accumulated_output_tokens || 0,
|
|
localInputTokens: 0,
|
|
localOutputTokens: 0,
|
|
session,
|
|
});
|
|
|
|
const recipe = session?.recipe;
|
|
|
|
useEffect(() => {
|
|
if (!recipe) return;
|
|
|
|
(async () => {
|
|
const accepted = await window.electron.hasAcceptedRecipeBefore(recipe);
|
|
setHasNotAcceptedRecipe(!accepted);
|
|
|
|
if (!accepted) {
|
|
const scanResult = await scanRecipe(recipe);
|
|
setHasRecipeSecurityWarnings(scanResult.has_security_warnings);
|
|
}
|
|
})();
|
|
}, [recipe]);
|
|
|
|
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,
|
|
},
|
|
});
|
|
};
|
|
|
|
window.addEventListener(AppEvents.SESSION_FORKED, handleSessionForked);
|
|
|
|
return () => {
|
|
window.removeEventListener(AppEvents.SESSION_FORKED, handleSessionForked);
|
|
};
|
|
}, [location.pathname, navigate]);
|
|
|
|
const handleRecipeCreated = (recipe: Recipe) => {
|
|
toastSuccess({
|
|
title: 'Recipe created successfully!',
|
|
msg: `"${recipe.title}" has been saved and is ready to use.`,
|
|
});
|
|
};
|
|
|
|
const showPopularTopics =
|
|
messages.length === 0 && !initialMessage && chatState === ChatState.Idle;
|
|
|
|
const chat: ChatType = {
|
|
messages,
|
|
recipe,
|
|
sessionId,
|
|
name: session?.name || 'No Session',
|
|
};
|
|
|
|
const lastSetNameRef = useRef<string>('');
|
|
|
|
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]);
|
|
|
|
// Only use initialMessage for the prompt if it hasn't been submitted yet
|
|
// 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 (
|
|
<div className="h-full flex flex-col min-h-0">
|
|
<MainPanelLayout
|
|
backgroundColor={'bg-background-muted'}
|
|
removeTopPadding={true}
|
|
{...customMainLayoutProps}
|
|
>
|
|
{renderHeader && renderHeader()}
|
|
<div className="flex flex-col flex-1 mb-0.5 min-h-0 relative">
|
|
<div className="flex-1 bg-background-default rounded-b-2xl flex items-center justify-center">
|
|
<div className="flex flex-col items-center justify-center p-8">
|
|
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-4 rounded-lg mb-4 max-w-md">
|
|
<h3 className="font-semibold mb-2">Failed to Load Session</h3>
|
|
<p className="text-sm">{sessionLoadError}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setView('chat');
|
|
}}
|
|
className="px-4 py-2 text-center cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-lg transition-all duration-150"
|
|
>
|
|
Go home
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</MainPanelLayout>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full flex flex-col min-h-0">
|
|
<MainPanelLayout
|
|
backgroundColor={'bg-background-muted'}
|
|
removeTopPadding={true}
|
|
{...customMainLayoutProps}
|
|
>
|
|
{/* Custom header */}
|
|
{renderHeader && renderHeader()}
|
|
|
|
{/* Chat container with sticky recipe header */}
|
|
<div className="flex flex-col flex-1 mb-0.5 min-h-0 relative">
|
|
<div className="absolute top-3 right-4 z-20 flex flex-row items-center gap-1">
|
|
<Goose className="size-5 goose-icon-animation" />
|
|
<span className="text-sm leading-none text-text-muted -translate-y-px">goose</span>
|
|
<EnvironmentBadge className="translate-y-px" />
|
|
</div>
|
|
|
|
<ScrollArea
|
|
ref={scrollRef}
|
|
className={`flex-1 bg-background-default rounded-b-2xl min-h-0 relative ${contentClassName}`}
|
|
autoScroll
|
|
onDrop={handleDrop}
|
|
onDragOver={handleDragOver}
|
|
data-drop-zone="true"
|
|
paddingX={6}
|
|
paddingY={0}
|
|
>
|
|
{recipe?.title && (
|
|
<div className="sticky top-0 z-10 bg-background-default px-0 -mx-6 mb-6 pt-6">
|
|
<RecipeHeader title={recipe.title} />
|
|
</div>
|
|
)}
|
|
|
|
{recipe && (
|
|
<div className={hasStartedUsingRecipe ? 'mb-6' : ''}>
|
|
<RecipeActivities
|
|
append={(text: string) => handleSubmit(text)}
|
|
activities={Array.isArray(recipe.activities) ? recipe.activities : null}
|
|
title={recipe.title}
|
|
parameterValues={session?.user_recipe_values || {}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{messages.length > 0 || recipe ? (
|
|
<>
|
|
<SearchView>
|
|
<ProgressiveMessageList
|
|
messages={messages}
|
|
chat={{ sessionId }}
|
|
toolCallNotifications={toolCallNotifications}
|
|
append={(text: string) => handleSubmit(text)}
|
|
isUserMessage={(m: Message) => m.role === 'user'}
|
|
isStreamingMessage={chatState !== ChatState.Idle}
|
|
onRenderingComplete={handleRenderingComplete}
|
|
onMessageUpdate={onMessageUpdate}
|
|
submitElicitationResponse={submitElicitationResponse}
|
|
/>
|
|
</SearchView>
|
|
|
|
<div className="block h-8" />
|
|
</>
|
|
) : !recipe && showPopularTopics ? (
|
|
<PopularChatTopics append={(text: string) => handleSubmit(text)} />
|
|
) : null}
|
|
</ScrollArea>
|
|
|
|
{chatState !== ChatState.Idle && (
|
|
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
|
|
<LoadingGoose
|
|
chatState={chatState}
|
|
message={
|
|
messages.length > 0
|
|
? getThinkingMessage(messages[messages.length - 1])
|
|
: undefined
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
|
|
>
|
|
<ChatInput
|
|
inputRef={chatInputRef}
|
|
sessionId={sessionId}
|
|
handleSubmit={handleFormSubmit}
|
|
chatState={chatState}
|
|
setChatState={setChatState}
|
|
onStop={stopStreaming}
|
|
commandHistory={commandHistory}
|
|
initialValue={initialPrompt}
|
|
setView={setView}
|
|
totalTokens={tokenState?.totalTokens ?? session?.total_tokens ?? undefined}
|
|
accumulatedInputTokens={
|
|
tokenState?.accumulatedInputTokens ?? session?.accumulated_input_tokens ?? undefined
|
|
}
|
|
accumulatedOutputTokens={
|
|
tokenState?.accumulatedOutputTokens ?? session?.accumulated_output_tokens ?? undefined
|
|
}
|
|
droppedFiles={droppedFiles}
|
|
onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing
|
|
messages={messages}
|
|
disableAnimation={disableAnimation}
|
|
sessionCosts={sessionCosts}
|
|
recipe={recipe}
|
|
recipeAccepted={!hasNotAcceptedRecipe}
|
|
initialPrompt={initialPrompt}
|
|
toolCount={toolCount || 0}
|
|
{...customChatInputProps}
|
|
/>
|
|
</div>
|
|
</MainPanelLayout>
|
|
|
|
{recipe && (
|
|
<RecipeWarningModal
|
|
isOpen={!!hasNotAcceptedRecipe}
|
|
onConfirm={() => 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 && (
|
|
<ParameterInputModal
|
|
parameters={recipe.parameters}
|
|
onSubmit={setRecipeUserParams}
|
|
onClose={() => setView('chat')}
|
|
initialValues={
|
|
(window.appConfig?.get('recipeParameters') as Record<string, string> | undefined) ||
|
|
undefined
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<CreateRecipeFromSessionModal
|
|
isOpen={isCreateRecipeModalOpen}
|
|
onClose={() => setIsCreateRecipeModalOpen(false)}
|
|
sessionId={chat.sessionId}
|
|
onRecipeCreated={handleRecipeCreated}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function BaseChat(props: BaseChatProps) {
|
|
return <BaseChatContent {...props} />;
|
|
}
|