Files
tkmind_go/ui/desktop/src/components/pair.tsx
T

218 lines
7.9 KiB
TypeScript

/**
* Pair Component
*
* The Pair component represents the active conversation mode in the Goose Desktop application.
* This is where users engage in ongoing conversations with the AI assistant after transitioning
* from the Hub's initial welcome screen.
*
* Key Responsibilities:
* - Manages active chat sessions with full message history
* - Handles transitions from Hub with initial input processing
* - Provides the main conversational interface for extended interactions
* - Enables local storage persistence for conversation continuity
* - Supports all advanced chat features like file attachments, tool usage, etc.
*
* Navigation Flow:
* Hub (initial message) → Pair (active conversation) → Hub (new session)
*
* The Pair component is essentially a specialized wrapper around BaseChat that:
* - Processes initial input from the Hub transition
* - Enables conversation persistence
* - Provides the full-featured chat experience
*
* Unlike Hub, Pair assumes an active conversation state and focuses on
* maintaining conversation flow rather than onboarding new users.
*/
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { type View, ViewOptions } from '../App';
import { Message } from '../types/message';
import BaseChat from './BaseChat';
import ParameterInputModal from './ParameterInputModal';
import { useRecipeManager } from '../hooks/useRecipeManager';
import { useIsMobile } from '../hooks/use-mobile';
import { useSidebar } from './ui/sidebar';
import { Recipe } from '../recipe';
import 'react-toastify/dist/ReactToastify.css';
import { cn } from '../utils';
export interface ChatType {
id: string;
title: string;
messageHistoryIndex: number;
messages: Message[];
recipeConfig?: Recipe | null; // Add recipe configuration to chat state
}
export default function Pair({
chat,
setChat,
setView,
setIsGoosehintsModalOpen,
}: {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) {
const location = useLocation();
const isMobile = useIsMobile();
const { state: sidebarState } = useSidebar();
const [hasProcessedInitialInput, setHasProcessedInitialInput] = useState(false);
const [shouldAutoSubmit, setShouldAutoSubmit] = useState(false);
const [initialMessage, setInitialMessage] = useState<string | null>(null);
const [isTransitioningFromHub, setIsTransitioningFromHub] = useState(false);
// Get recipe configuration and parameter handling
const {
recipeConfig,
initialPrompt: recipeInitialPrompt,
isParameterModalOpen,
setIsParameterModalOpen,
handleParameterSubmit,
} = useRecipeManager(chat.messages, location.state);
// Handle recipe loading from recipes view - reset chat if needed
useEffect(() => {
if (location.state?.resetChat && location.state?.recipeConfig) {
// Reset the chat to start fresh with the recipe
const newChat = {
id: chat.id, // Keep the same ID to maintain the session
title: location.state.recipeConfig.title || 'Recipe Chat',
messages: [], // Clear messages to start fresh
messageHistoryIndex: 0,
recipeConfig: location.state.recipeConfig, // Set the recipe config in chat state
};
setChat(newChat);
// Clear the location state to prevent re-processing
window.history.replaceState({}, '', '/pair');
}
}, [location.state, chat.id, setChat]);
// Handle initial message from hub page
useEffect(() => {
const messageFromHub = location.state?.initialMessage;
// Reset processing state when we have a new message from hub
if (messageFromHub) {
// Set transitioning state to prevent showing popular topics
setIsTransitioningFromHub(true);
// If this is a different message than what we processed before, reset the flag
if (messageFromHub !== initialMessage) {
setHasProcessedInitialInput(false);
}
if (!hasProcessedInitialInput) {
setHasProcessedInitialInput(true);
setInitialMessage(messageFromHub);
setShouldAutoSubmit(true);
// Clear the location state to prevent re-processing
window.history.replaceState({}, '', '/pair');
}
}
}, [location.state, hasProcessedInitialInput, initialMessage, chat]);
// Auto-submit the initial message after it's been set and component is ready
useEffect(() => {
if (shouldAutoSubmit && initialMessage) {
// Wait for the component to be fully rendered
const timer = setTimeout(() => {
// Try to trigger form submission programmatically
const textarea = document.querySelector(
'textarea[data-testid="chat-input"]'
) as HTMLTextAreaElement;
const form = textarea?.closest('form');
if (textarea && form) {
// Set the textarea value
textarea.value = initialMessage;
// eslint-disable-next-line no-undef
textarea.dispatchEvent(new Event('input', { bubbles: true }));
// Focus the textarea
textarea.focus();
// Simulate Enter key press to trigger submission
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
});
textarea.dispatchEvent(enterEvent);
setShouldAutoSubmit(false);
}
}, 500); // Give more time for the component to fully mount
// eslint-disable-next-line no-undef
return () => clearTimeout(timer);
}
// Return undefined when condition is not met
return undefined;
}, [shouldAutoSubmit, initialMessage]);
// Custom message submit handler
const handleMessageSubmit = (message: string) => {
// This is called after a message is submitted
setShouldAutoSubmit(false);
setIsTransitioningFromHub(false); // Clear transitioning state once message is submitted
console.log('Message submitted:', message);
};
// Custom message stream finish handler to handle recipe auto-execution
const handleMessageStreamFinish = () => {
// This will be called with the proper append function from BaseChat
// For now, we'll handle auto-execution in the BaseChat component
};
// Determine the initial value for the chat input
// Priority: Hub message > Recipe prompt > empty
const initialValue = initialMessage || recipeInitialPrompt || undefined;
// Custom chat input props for Pair-specific behavior
const customChatInputProps = {
// Pass initial message from Hub or recipe prompt
initialValue,
};
// Custom content before messages
const renderBeforeMessages = () => {
return <div>{/* Any Pair-specific content before messages can go here */}</div>;
};
return (
<>
<BaseChat
chat={chat}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
enableLocalStorage={true} // Enable local storage for Pair mode
onMessageSubmit={handleMessageSubmit}
onMessageStreamFinish={handleMessageStreamFinish}
renderBeforeMessages={renderBeforeMessages}
customChatInputProps={customChatInputProps}
contentClassName={cn('pr-1 pb-10', (isMobile || sidebarState === 'collapsed') && 'pt-11')} // Use dynamic content class with mobile margin and sidebar state
showPopularTopics={!isTransitioningFromHub} // Don't show popular topics while transitioning from Hub
suppressEmptyState={isTransitioningFromHub} // Suppress all empty state content while transitioning from Hub
/>
{/* Recipe Parameter Modal */}
{isParameterModalOpen && recipeConfig?.parameters && (
<ParameterInputModal
parameters={recipeConfig.parameters}
onSubmit={handleParameterSubmit}
onClose={() => setIsParameterModalOpen(false)}
/>
)}
</>
);
}