Files
tkmind_go/ui/desktop/src/components/BaseChat2.tsx
T
2025-10-29 23:23:25 -04:00

344 lines
12 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } 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 } from '../types/message';
import ParameterInputModal from './ParameterInputModal';
interface BaseChatProps {
setChat: (chat: ChatType) => void;
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
onMessageSubmit?: (message: string) => void;
renderHeader?: () => React.ReactNode;
customChatInputProps?: Record<string, unknown>;
customMainLayoutProps?: Record<string, unknown>;
suppressEmptyState: boolean;
sessionId: string;
initialMessage?: string;
}
function BaseChatContent({
setIsGoosehintsModalOpen,
renderHeader,
customChatInputProps = {},
customMainLayoutProps = {},
sessionId,
initialMessage,
}: BaseChatProps) {
const location = useLocation();
const scrollRef = useRef<ScrollAreaHandle>(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', (isMobile || sidebarState === 'collapsed') && 'pt-11');
// Use shared file drop
const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop();
const onStreamFinish = useCallback(() => {}, []);
const {
session,
messages,
chatState,
handleSubmit,
stopStreaming,
sessionLoadError,
setRecipeUserParams,
tokenState,
} = useChatStream({
sessionId,
onStreamFinish,
initialMessage,
});
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('scroll-chat-to-bottom', handleGlobalScrollRequest);
return () => window.removeEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest);
}, []);
const renderProgressiveMessageList = (chat: ChatType) => (
<>
<ProgressiveMessageList
messages={messages}
chat={chat}
// toolCallNotifications={toolCallNotifications}
// appendMessage={(newMessage) => {
// const updatedMessages = [...messages, newMessage];
// setMessages(updatedMessages);
// }}
isUserMessage={(m: Message) => m.role === 'user'}
isStreamingMessage={chatState !== ChatState.Idle}
// onMessageUpdate={onMessageUpdate}
onRenderingComplete={handleRenderingComplete}
/>
</>
);
const showPopularTopics =
messages.length === 0 && !initialMessage && chatState === ChatState.Idle;
const chat: ChatType = {
messageHistoryIndex: 0,
messages,
recipe,
sessionId,
name: session?.name || 'No Session',
};
const initialPrompt = messages.length == 0 && recipe?.prompt ? recipe.prompt : '';
return (
<div className="h-full flex flex-col min-h-0">
<h2>Warning: BaseChat2!</h2>
<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">
<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={recipeParameters || {}}
/>
</div>
)}
{sessionLoadError && (
<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>
)}
{/* Messages or Popular Topics */}
{messages.length > 0 || recipe ? (
<>
<SearchView>{renderProgressiveMessageList(chat)}</SearchView>
<div className="block h-8" />
</>
) : !recipe && showPopularTopics ? (
<PopularChatTopics append={(text: string) => handleSubmit(text)} />
) : null}
</ScrollArea>
{chatState !== ChatState.Idle && !sessionLoadError && (
<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
sessionId={sessionId}
handleSubmit={handleFormSubmit}
chatState={chatState}
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}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
recipe={recipe}
recipeAccepted={!hasNotAcceptedRecipe}
initialPrompt={initialPrompt}
toolCount={toolCount || 0}
autoSubmit={false}
{...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')}
/>
)}
{/*/!* Create Recipe from Session Modal *!/*/}
{/*<CreateRecipeFromSessionModal*/}
{/* isOpen={isCreateRecipeModalOpen}*/}
{/* onClose={() => setIsCreateRecipeModalOpen(false)}*/}
{/* sessionId={chat.sessionId}*/}
{/* onRecipeCreated={handleRecipeCreated}*/}
{/*/>*/}
</div>
);
}
export default function BaseChat(props: BaseChatProps) {
return <BaseChatContent {...props} />;
}