refactored hub and pair to use shared logic and eliminate duplicate code

This commit is contained in:
Zane Staggs
2025-07-08 19:44:59 -07:00
parent f7abfbc317
commit 52ca0b90d9
8 changed files with 1179 additions and 1589 deletions
+317
View File
@@ -0,0 +1,317 @@
import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import GooseMessage from './GooseMessage';
import UserMessage from './UserMessage';
import { SearchView } from './conversation/SearchView';
import { AgentHeader } from './AgentHeader';
import LayingEggLoader from './LayingEggLoader';
import { SessionSummaryModal } from './context_management/SessionSummaryModal';
import {
ChatContextManagerProvider,
useChatContextManager,
} from './context_management/ChatContextManager';
import { ContextHandler } from './context_management/ContextHandler';
import { type View, ViewOptions } from '../App';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import ChatInput from './ChatInput';
import { useChatEngine } from '../hooks/useChatEngine';
import { useRecipeManager } from '../hooks/useRecipeManager';
import { useSessionContinuation } from '../hooks/useSessionContinuation';
import { useFileDrop } from '../hooks/useFileDrop';
import { useCostTracking } from '../hooks/useCostTracking';
import { Message } from '../types/message';
export interface ChatType {
id: string;
title: string;
messageHistoryIndex: number;
messages: Message[];
}
interface BaseChatProps {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
enableLocalStorage?: boolean;
onMessageStreamFinish?: () => void;
renderHeader?: () => React.ReactNode;
renderBeforeMessages?: () => React.ReactNode;
renderAfterMessages?: () => React.ReactNode;
customChatInputProps?: Record<string, unknown>;
customMainLayoutProps?: Record<string, unknown>;
}
function BaseChatContent({
chat,
setChat,
setView,
enableLocalStorage = false,
onMessageStreamFinish,
renderHeader,
renderBeforeMessages,
renderAfterMessages,
customChatInputProps = {},
customMainLayoutProps = {},
}: BaseChatProps) {
const location = useLocation();
// Get disableAnimation from location state
const disableAnimation = location.state?.disableAnimation || false;
const {
summaryContent,
summarizedThread,
isSummaryModalOpen,
resetMessagesWithSummary,
closeSummaryModal,
updateSummary,
hasContextHandlerContent,
getContextHandlerType,
} = useChatContextManager();
// Use shared chat engine
const {
messages,
filteredMessages,
ancestorMessages,
setAncestorMessages,
append,
isLoading,
error,
setMessages,
input: _input,
setInput: _setInput,
handleSubmit: engineHandleSubmit,
onStopGoose,
sessionTokenCount,
sessionInputTokens,
sessionOutputTokens,
localInputTokens,
localOutputTokens,
commandHistory,
toolCallNotifications,
updateMessageStreamBody,
sessionMetadata,
isUserMessage,
} = useChatEngine({
chat,
setChat,
onMessageStreamFinish,
enableLocalStorage,
});
// Use shared recipe manager
const { recipeConfig, initialPrompt, isGeneratingRecipe } = useRecipeManager(messages);
// Use shared session continuation
useSessionContinuation({
chat,
setChat,
summarizedThread,
updateMessageStreamBody,
});
// Use shared file drop
const { droppedFiles, handleDrop, handleDragOver } = useFileDrop();
// Use shared cost tracking
const { sessionCosts } = useCostTracking({
sessionInputTokens,
sessionOutputTokens,
localInputTokens,
localOutputTokens,
sessionMetadata,
});
useEffect(() => {
// Log all messages when the component first mounts
window.electron.logInfo(
'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2)
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty dependency array means this runs once on mount
// Handle submit with summary reset support
const handleSubmit = (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
const combinedTextFromInput = customEvent.detail?.value || '';
const onSummaryReset =
summarizedThread.length > 0
? () => {
resetMessagesWithSummary(
messages,
setMessages,
ancestorMessages,
setAncestorMessages,
summaryContent
);
}
: undefined;
engineHandleSubmit(combinedTextFromInput, onSummaryReset);
};
return (
<div>
<MainPanelLayout {...customMainLayoutProps}>
{/* Loader when generating recipe */}
{isGeneratingRecipe && <LayingEggLoader />}
{/* Custom header */}
{renderHeader && renderHeader()}
<div
className="flex flex-col min-w-0 flex-1 overflow-y-scroll relative"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* Custom content before messages */}
{renderBeforeMessages && renderBeforeMessages()}
{/* Recipe agent header */}
{recipeConfig?.title && messages.length > 0 && (
<AgentHeader
title={recipeConfig.title}
profileInfo={
recipeConfig.profile
? `${recipeConfig.profile} - ${recipeConfig.mcps || 12} MCPs`
: undefined
}
onChangeProfile={() => {
console.log('Change profile clicked');
}}
/>
)}
{/* Messages */}
{messages.length > 0 && (
<>
<SearchView>
{filteredMessages.map((message, index) => {
const isUser = isUserMessage(message);
return (
<div
key={message.id || index}
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} ${isUser ? 'user' : 'assistant'}`}
data-testid="message-container"
>
{isUser ? (
<>
{hasContextHandlerContent(message) ? (
<ContextHandler
messages={messages}
messageId={message.id ?? message.created.toString()}
chatId={chat.id}
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
contextType={getContextHandlerType(message)}
/>
) : (
<UserMessage message={message} />
)}
</>
) : (
<>
{hasContextHandlerContent(message) ? (
<ContextHandler
messages={messages}
messageId={message.id ?? message.created.toString()}
chatId={chat.id}
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
contextType={getContextHandlerType(message)}
/>
) : (
<GooseMessage
messageHistoryIndex={chat?.messageHistoryIndex}
message={message}
messages={messages}
append={append}
appendMessage={(newMessage) => {
const updatedMessages = [...messages, newMessage];
setMessages(updatedMessages);
}}
toolCallNotifications={toolCallNotifications}
/>
)}
</>
)}
</div>
);
})}
</SearchView>
{error && (
<div className="flex flex-col items-center justify-center p-4">
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
{error.message || 'Honk! Goose experienced an error while responding'}
</div>
<div
className="px-3 py-2 mt-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"
onClick={async () => {
// Find the last user message
const lastUserMessage = messages.reduceRight(
(found, m) => found || (m.role === 'user' ? m : null),
null as Message | null
);
if (lastUserMessage) {
append(lastUserMessage);
}
}}
>
Retry Last Message
</div>
</div>
)}
<div className="block h-8" />
</>
)}
{/* Custom content after messages */}
{renderAfterMessages && renderAfterMessages()}
</div>
<div
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
>
<ChatInput
handleSubmit={handleSubmit}
isLoading={isLoading}
onStop={onStopGoose}
commandHistory={commandHistory}
initialValue={_input || initialPrompt}
setView={setView}
numTokens={sessionTokenCount}
inputTokens={sessionInputTokens || localInputTokens}
outputTokens={sessionOutputTokens || localOutputTokens}
droppedFiles={droppedFiles}
messages={messages}
setMessages={setMessages}
disableAnimation={disableAnimation}
sessionCosts={sessionCosts}
{...customChatInputProps}
/>
</div>
</MainPanelLayout>
<SessionSummaryModal
isOpen={isSummaryModalOpen}
onClose={closeSummaryModal}
onSave={(editedContent) => {
updateSummary(editedContent);
closeSummaryModal();
}}
summaryContent={summaryContent}
/>
</div>
);
}
export default function BaseChat(props: BaseChatProps) {
return (
<ChatContextManagerProvider>
<BaseChatContent {...props} />
</ChatContextManagerProvider>
);
}
File diff suppressed because it is too large Load Diff
+35 -669
View File
@@ -1,47 +1,9 @@
import React, {
useEffect,
useRef,
useState,
useMemo,
useCallback,
useContext,
createContext,
} from 'react';
import { getApiUrl } from '../config';
import GooseMessage from './GooseMessage';
import ChatInput from './ChatInput';
import { type View, ViewOptions } from '../App';
import LoadingGoose from './LoadingGoose';
import { ScrollAreaHandle } from './ui/scroll-area';
import UserMessage from './UserMessage';
import { SearchView } from './conversation/SearchView';
import { createRecipe } from '../recipe';
import { AgentHeader } from './AgentHeader';
import LayingEggLoader from './LayingEggLoader';
import { fetchSessionDetails, generateSessionId } from '../sessions';
import 'react-toastify/dist/ReactToastify.css';
import { useMessageStream } from '../hooks/useMessageStream';
import { SessionSummaryModal } from './context_management/SessionSummaryModal';
import { Recipe } from '../recipe';
import {
ChatContextManagerProvider,
useChatContextManager,
} from './context_management/ChatContextManager';
import { ContextHandler } from './context_management/ContextHandler';
import { LocalMessageStorage } from '../utils/localMessageStorage';
import {
Message,
createUserMessage,
ToolCall,
ToolCallResult,
ToolRequestMessageContent,
ToolResponseMessageContent,
ToolConfirmationRequestMessageContent,
getTextContent,
TextContent,
} from '../types/message';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import { useEffect, useState, useContext, createContext } from 'react';
import { useLocation } from 'react-router-dom';
import { type View, ViewOptions } from '../App';
import { Message } from '../types/message';
import BaseChat from './BaseChat';
import 'react-toastify/dist/ReactToastify.css';
// Context for sharing current model info
const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null);
@@ -54,19 +16,7 @@ export interface ChatType {
messages: Message[];
}
// Helper function to determine if a message is a user message
const isUserMessage = (message: Message): boolean => {
if (message.role === 'assistant') {
return false;
}
if (message.content.every((c) => c.type === 'toolConfirmationRequest')) {
return false;
}
return true;
};
export default function Pair({
readyForAutoUserPrompt,
chat,
setChat,
setView,
@@ -77,635 +27,51 @@ export default function Pair({
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) {
return (
<ChatContextManagerProvider>
<PairContent
readyForAutoUserPrompt={readyForAutoUserPrompt}
chat={chat}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
</ChatContextManagerProvider>
);
}
function PairContent({
chat,
setChat,
setView,
}: {
readyForAutoUserPrompt: boolean;
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) {
return <PairContentWithSidebar chat={chat} setChat={setChat} setView={setView} />;
}
function PairContentWithSidebar({
chat,
setChat,
setView,
}: {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
}) {
const location = useLocation();
const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now());
const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false);
const [sessionTokenCount, setSessionTokenCount] = useState<number>(0);
const [ancestorMessages, setAncestorMessages] = useState<Message[]>([]);
const [droppedFiles, setDroppedFiles] = useState<string[]>([]);
const [hasProcessedInitialInput, setHasProcessedInitialInput] = useState(false);
// Get disableAnimation from location state
const disableAnimation = location.state?.disableAnimation || false;
const scrollRef = useRef<ScrollAreaHandle>(null);
const {
summaryContent,
summarizedThread,
isSummaryModalOpen,
resetMessagesWithSummary,
closeSummaryModal,
updateSummary,
hasContextHandlerContent,
getContextHandlerType,
} = useChatContextManager();
useEffect(() => {
// Log all messages when the component first mounts
window.electron.logInfo(
'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2)
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty dependency array means this runs once on mount;
// Get recipeConfig directly from appConfig
const recipeConfig = window.appConfig.get('recipeConfig') as Recipe | null;
// Store message in global history when it's added
const storeMessageInHistory = useCallback((message: Message) => {
if (isUserMessage(message)) {
const text = getTextContent(message);
if (text) {
LocalMessageStorage.addMessage(text);
}
}
}, []);
const {
messages,
append: originalAppend,
stop,
isLoading,
error,
setMessages,
input: _input,
setInput: _setInput,
handleInputChange: _handleInputChange,
handleSubmit: _submitMessage,
updateMessageStreamBody,
notifications,
} = useMessageStream({
api: getApiUrl('/reply'),
id: chat.id,
initialMessages: chat.messages,
body: { session_id: chat.id, session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR') },
onFinish: async (_message, _reason) => {
window.electron.stopPowerSaveBlocker();
setTimeout(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 300);
const timeSinceLastInteraction = Date.now() - lastInteractionTime;
window.electron.logInfo('last interaction:' + lastInteractionTime);
if (timeSinceLastInteraction > 60000) {
// 60000ms = 1 minute
window.electron.showNotification({
title: 'Goose finished the task.',
body: 'Click here to expand.',
});
}
// Always emit refresh event when message stream finishes for new sessions
// Check if this is a new session by looking at the current session ID format
const isNewSession = chat.id && chat.id.match(/^\d{8}_\d{6}$/);
if (isNewSession) {
console.log(
'Pair: Message stream finished for new session, emitting message-stream-finished event'
);
// Emit event to trigger session refresh
window.dispatchEvent(new CustomEvent('message-stream-finished'));
}
},
});
// Wrap append to store messages in global history
const append = useCallback(
(messageOrString: Message | string) => {
const message =
typeof messageOrString === 'string' ? createUserMessage(messageOrString) : messageOrString;
storeMessageInHistory(message);
// If this is the first message in a new session, trigger a refresh immediately
// Only trigger if we're starting a completely new session (no existing messages)
if (messages.length === 0 && chat.messages.length === 0) {
console.log('Pair: New session detected, emitting session-created event');
// Emit event to indicate a new session is being created
window.dispatchEvent(new CustomEvent('session-created'));
}
return originalAppend(message);
},
[originalAppend, storeMessageInHistory, messages.length, chat.messages.length]
);
// for CLE events -- create a new session id for the next set of messages
useEffect(() => {
// If we're in a continuation session, update the chat ID
if (summarizedThread.length > 0) {
const newSessionId = generateSessionId();
// Update the session ID in the chat object
setChat({
...chat,
id: newSessionId!,
title: `Continued from ${chat.id}`,
messageHistoryIndex: summarizedThread.length,
});
// Update the body used by useMessageStream to send future messages to the new session
if (summarizedThread.length > 0 && updateMessageStreamBody) {
updateMessageStreamBody({
session_id: newSessionId,
session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'),
});
}
}
// only update if summarizedThread length changes from 0 -> 1+
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
summarizedThread.length > 0,
]);
// Listen for make-agent-from-chat event
useEffect(() => {
const handleMakeAgent = async () => {
window.electron.logInfo('Making recipe from chat...');
setIsGeneratingRecipe(true);
try {
// Create recipe directly from chat messages
const createRecipeRequest = {
messages: messages,
title: '',
description: '',
};
const response = await createRecipe(createRecipeRequest);
if (response.error) {
throw new Error(`Failed to create recipe: ${response.error}`);
}
window.electron.logInfo('Created recipe:');
window.electron.logInfo(JSON.stringify(response.recipe, null, 2));
// First, verify the recipe data
if (!response.recipe) {
throw new Error('No recipe data received');
}
// Create a new window for the recipe editor
console.log('Opening recipe editor with config:', response.recipe);
const recipeConfig = {
id: response.recipe.title || 'untitled',
title: response.recipe.title || 'Untitled Recipe',
description: response.recipe.description || '',
instructions: response.recipe.instructions || '',
activities: response.recipe.activities || [],
prompt: response.recipe.prompt || '',
};
window.electron.createChatWindow(
undefined, // query
undefined, // dir
undefined, // version
undefined, // resumeSessionId
recipeConfig, // recipe config
'recipeEditor' // view type
);
window.electron.logInfo('Opening recipe editor window');
} catch (error) {
window.electron.logInfo('Failed to create recipe:');
const errorMessage = error instanceof Error ? error.message : String(error);
window.electron.logInfo(errorMessage);
} finally {
setIsGeneratingRecipe(false);
}
};
window.addEventListener('make-agent-from-chat', handleMakeAgent);
return () => {
window.removeEventListener('make-agent-from-chat', handleMakeAgent);
};
}, [messages]);
// Update chat messages when they change and save to sessionStorage
useEffect(() => {
// @ts-expect-error - TypeScript being overly strict about the return type
setChat((prevChat: ChatType) => ({ ...prevChat, messages }));
}, [messages, setChat]);
// Pre-fill input with recipe prompt instead of auto-sending it
const initialPrompt = useMemo(() => {
return recipeConfig?.prompt || '';
}, [recipeConfig?.prompt]);
// Handle submit
const handleSubmit = (e: React.FormEvent) => {
window.electron.startPowerSaveBlocker();
const customEvent = e as unknown as CustomEvent;
// ChatInput now sends a single 'value' field with text and appended image paths
const combinedTextFromInput = customEvent.detail?.value || '';
if (combinedTextFromInput.trim()) {
setLastInteractionTime(Date.now());
// createUserMessage was reverted to only accept text.
// It will create a Message with a single TextContent part containing text + paths.
const userMessage = createUserMessage(combinedTextFromInput.trim());
if (summarizedThread.length > 0) {
resetMessagesWithSummary(
messages,
setMessages,
ancestorMessages,
setAncestorMessages,
summaryContent
);
setTimeout(() => {
append(userMessage);
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 150);
} else {
append(userMessage);
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}
} else {
// If nothing was actually submitted (e.g. empty input and no images pasted)
window.electron.stopPowerSaveBlocker();
}
};
if (error) {
console.log('Error:', error);
}
const onStopGoose = () => {
stop();
setLastInteractionTime(Date.now());
window.electron.stopPowerSaveBlocker();
// Handle stopping the message stream
const lastMessage = messages[messages.length - 1];
// Check if there are any messages before proceeding
if (!lastMessage) {
return;
}
// check if the last user message has any tool response(s)
const isToolResponse = lastMessage.content.some(
(content): content is ToolResponseMessageContent => content.type == 'toolResponse'
);
// isUserMessage also checks if the message is a toolConfirmationRequest
// check if the last message is a real user's message
if (lastMessage && isUserMessage(lastMessage) && !isToolResponse) {
// Get the text content from the last message before removing it
const textContent = lastMessage.content.find((c): c is TextContent => c.type === 'text');
const textValue = textContent?.text || '';
// Set the text back to the input field
_setInput(textValue);
// Remove the last user message if it's the most recent one
if (messages.length > 1) {
setMessages(messages.slice(0, -1));
} else {
setMessages([]);
}
// Interruption occured after a tool has completed, but no assistant reply
// handle his if we want to popup a message too the user
// } else if (lastMessage && isUserMessage(lastMessage) && isToolResponse) {
} else if (!isUserMessage(lastMessage)) {
// the last message was an assistant message
// check if we have any tool requests or tool confirmation requests
const toolRequests: [string, ToolCallResult<ToolCall>][] = lastMessage.content
.filter(
(content): content is ToolRequestMessageContent | ToolConfirmationRequestMessageContent =>
content.type === 'toolRequest' || content.type === 'toolConfirmationRequest'
)
.map((content) => {
if (content.type === 'toolRequest') {
return [content.id, content.toolCall];
} else {
// extract tool call from confirmation
const toolCall: ToolCallResult<ToolCall> = {
status: 'success',
value: {
name: content.toolName,
arguments: content.arguments,
},
};
return [content.id, toolCall];
}
});
if (toolRequests.length !== 0) {
// This means we were interrupted during a tool request
// Create tool responses for all interrupted tool requests
let responseMessage: Message = {
display: true,
sendToLLM: true,
role: 'user',
created: Date.now(),
content: [],
};
const notification = 'Interrupted by the user to make a correction';
// generate a response saying it was interrupted for each tool request
for (const [reqId, _] of toolRequests) {
const toolResponse: ToolResponseMessageContent = {
type: 'toolResponse',
id: reqId,
toolResult: {
status: 'error',
error: notification,
},
};
responseMessage.content.push(toolResponse);
}
// Use an immutable update to add the response message to the messages array
setMessages([...messages, responseMessage]);
}
}
};
// Filter out standalone tool response messages for rendering
// They will be shown as part of the tool invocation in the assistant message
const filteredMessages = [...ancestorMessages, ...messages].filter((message) => {
// Only filter out when display is explicitly false
if (message.display === false) return false;
// Keep all assistant messages and user messages that aren't just tool responses
if (message.role === 'assistant') return true;
// For user messages, check if they're only tool responses
if (message.role === 'user') {
const hasOnlyToolResponses = message.content.every((c) => c.type === 'toolResponse');
const hasTextContent = message.content.some((c) => c.type === 'text');
const hasToolConfirmation = message.content.every(
(c) => c.type === 'toolConfirmationRequest'
);
// Keep the message if it has text content or tool confirmation or is not just tool responses
return hasTextContent || !hasOnlyToolResponses || hasToolConfirmation;
}
return true;
});
const commandHistory = useMemo(() => {
return filteredMessages
.reduce<string[]>((history, message) => {
if (isUserMessage(message)) {
const textContent = message.content.find((c): c is TextContent => c.type === 'text');
const text = textContent?.text?.trim();
if (text) {
history.push(text);
}
}
return history;
}, [])
.reverse();
}, [filteredMessages]);
// Fetch session metadata to get token count
useEffect(() => {
const fetchSessionTokens = async () => {
try {
const sessionDetails = await fetchSessionDetails(chat.id);
setSessionTokenCount(sessionDetails.metadata.total_tokens || 0);
} catch (err) {
console.error('Error fetching session token count:', err);
}
};
if (chat.id) {
fetchSessionTokens();
}
}, [chat.id, messages]);
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const files = e.dataTransfer.files;
if (files.length > 0) {
const paths: string[] = [];
for (let i = 0; i < files.length; i++) {
paths.push(window.electron.getPathForFile(files[i]));
}
setDroppedFiles(paths);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const toolCallNotifications = notifications.reduce((map, item) => {
const key = item.request_id;
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(item);
return map;
}, new Map());
// Handle initial input from hub page
useEffect(() => {
const initialInput = location.state?.initialInput;
const fromHub = location.state?.fromHub;
if (fromHub && initialInput && !hasProcessedInitialInput && messages.length === 0) {
// Auto-submit the initial input
const userMessage = createUserMessage(initialInput);
append(userMessage);
if (fromHub && initialInput && !hasProcessedInitialInput && chat.messages.length === 0) {
// Auto-submit the initial input - this would need to be handled by BaseChat
setHasProcessedInitialInput(true);
// Clear the location state to prevent re-processing
window.history.replaceState({}, '', '/pair');
}
}, [location.state, hasProcessedInitialInput, messages.length, append]);
}, [location.state, hasProcessedInitialInput, chat.messages.length]);
return (
<div>
<MainPanelLayout>
{/* Loader when generating recipe */}
{isGeneratingRecipe && <LayingEggLoader />}
<div
className="flex flex-col min-w-0 flex-1 overflow-y-scroll relative pl-6 px-4 pb-16 pt-2"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{recipeConfig?.title && messages.length > 0 && (
<AgentHeader
title={recipeConfig.title}
profileInfo={
recipeConfig.profile
? `${recipeConfig.profile} - ${recipeConfig.mcps || 12} MCPs`
: undefined
}
onChangeProfile={() => {
// Handle profile change
console.log('Change profile clicked');
}}
/>
)}
{messages.length > 0 && (
<>
<SearchView>
{filteredMessages.map((message, index) => {
const isUser = isUserMessage(message);
// const nextMessage = filteredMessages[index + 1];
return (
<div
key={message.id || index}
className={`relative px-2 ${index === 0 ? 'mt-0' : 'mt-2'} ${isUser ? 'user' : 'assistant'}`}
data-testid="message-container"
>
{isUser ? (
<>
{hasContextHandlerContent(message) ? (
<ContextHandler
messages={messages}
messageId={message.id ?? message.created.toString()}
chatId={chat.id}
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
contextType={getContextHandlerType(message)}
/>
) : (
<UserMessage message={message} />
)}
</>
) : (
<>
{/* Only render GooseMessage if it's not a message invoking some context management */}
{hasContextHandlerContent(message) ? (
<ContextHandler
messages={messages}
messageId={message.id ?? message.created.toString()}
chatId={chat.id}
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
contextType={getContextHandlerType(message)}
/>
) : (
<GooseMessage
messageHistoryIndex={chat?.messageHistoryIndex}
message={message}
messages={messages}
append={append}
appendMessage={(newMessage) => {
const updatedMessages = [...messages, newMessage];
setMessages(updatedMessages);
}}
toolCallNotifications={toolCallNotifications}
/>
)}
</>
)}
</div>
);
})}
</SearchView>
{error && (
<div className="flex flex-col items-center justify-center p-4">
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
{error.message || 'Honk! Goose experienced an error while responding'}
</div>
<div
className="px-3 py-2 mt-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"
onClick={async () => {
// Find the last user message
const lastUserMessage = messages.reduceRight(
(found, m) => found || (m.role === 'user' ? m : null),
null as Message | null
);
if (lastUserMessage) {
append(lastUserMessage);
}
}}
>
Retry Last Message
</div>
</div>
)}
<div className="block h-8" />
</>
)}
</div>
<div
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
>
<div className="px-6">{isLoading && <LoadingGoose />}</div>
<ChatInput
handleSubmit={handleSubmit}
isLoading={isLoading}
onStop={onStopGoose}
commandHistory={commandHistory}
initialValue={_input || initialPrompt}
setView={setView}
numTokens={sessionTokenCount}
droppedFiles={droppedFiles}
messages={messages}
setMessages={setMessages}
disableAnimation={disableAnimation}
/>
</div>
</MainPanelLayout>
<SessionSummaryModal
isOpen={isSummaryModalOpen}
onClose={closeSummaryModal}
onSave={(editedContent) => {
updateSummary(editedContent);
closeSummaryModal();
}}
summaryContent={summaryContent}
/>
// Custom content before messages for loading state
const renderBeforeMessages = () => (
<div className="px-6">
{/* Loading indicator will be handled by BaseChat's isLoading state */}
</div>
);
// Custom main layout props for Pair-specific styling
const customMainLayoutProps = {
className: 'pl-6 px-4 pb-16 pt-2',
};
// Custom chat input props for Pair-specific behavior
const customChatInputProps = {
// Any Pair-specific chat input customizations can go here
};
return (
<BaseChat
chat={chat}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
enableLocalStorage={true} // Enable local storage for Pair mode
renderBeforeMessages={renderBeforeMessages}
customMainLayoutProps={customMainLayoutProps}
customChatInputProps={customChatInputProps}
/>
);
}
+401
View File
@@ -0,0 +1,401 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getApiUrl } from '../config';
import { useMessageStream } from './useMessageStream';
import { fetchSessionDetails } from '../sessions';
import { LocalMessageStorage } from '../utils/localMessageStorage';
import {
Message,
createUserMessage,
ToolCall,
ToolCallResult,
ToolRequestMessageContent,
ToolResponseMessageContent,
ToolConfirmationRequestMessageContent,
getTextContent,
TextContent,
} from '../types/message';
import { ChatType } from '../components/hub';
// Helper function to determine if a message is a user message
const isUserMessage = (message: Message): boolean => {
if (message.role === 'assistant') {
return false;
}
if (message.content.every((c) => c.type === 'toolConfirmationRequest')) {
return false;
}
return true;
};
interface UseChatEngineProps {
chat: ChatType;
setChat: (chat: ChatType) => void;
onMessageStreamFinish?: () => void;
enableLocalStorage?: boolean;
}
export const useChatEngine = ({
chat,
setChat,
onMessageStreamFinish,
enableLocalStorage = false,
}: UseChatEngineProps) => {
const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now());
const [sessionTokenCount, setSessionTokenCount] = useState<number>(0);
const [ancestorMessages, setAncestorMessages] = useState<Message[]>([]);
const [sessionInputTokens, setSessionInputTokens] = useState<number>(0);
const [sessionOutputTokens, setSessionOutputTokens] = useState<number>(0);
const [localInputTokens, setLocalInputTokens] = useState<number>(0);
const [localOutputTokens, setLocalOutputTokens] = useState<number>(0);
// Store message in global history when it's added (if enabled)
const storeMessageInHistory = useCallback(
(message: Message) => {
if (enableLocalStorage && isUserMessage(message)) {
const text = getTextContent(message);
if (text) {
LocalMessageStorage.addMessage(text);
}
}
},
[enableLocalStorage]
);
const {
messages,
append: originalAppend,
stop,
isLoading,
error,
setMessages,
input: _input,
setInput: _setInput,
handleInputChange: _handleInputChange,
handleSubmit: _submitMessage,
updateMessageStreamBody,
notifications,
sessionMetadata,
} = useMessageStream({
api: getApiUrl('/reply'),
id: chat.id,
initialMessages: chat.messages,
body: { session_id: chat.id, session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR') },
onFinish: async (_message, _reason) => {
window.electron.stopPowerSaveBlocker();
const timeSinceLastInteraction = Date.now() - lastInteractionTime;
window.electron.logInfo('last interaction:' + lastInteractionTime);
if (timeSinceLastInteraction > 60000) {
// 60000ms = 1 minute
window.electron.showNotification({
title: 'Goose finished the task.',
body: 'Click here to expand.',
});
}
// Always emit refresh event when message stream finishes for new sessions
// Check if this is a new session by looking at the current session ID format
const isNewSession = chat.id && chat.id.match(/^\d{8}_\d{6}$/);
if (isNewSession) {
console.log(
'ChatEngine: Message stream finished for new session, emitting message-stream-finished event'
);
// Emit event to trigger session refresh
window.dispatchEvent(new CustomEvent('message-stream-finished'));
}
onMessageStreamFinish?.();
},
});
// Wrap append to store messages in global history (if enabled)
const append = useCallback(
(messageOrString: Message | string) => {
const message =
typeof messageOrString === 'string' ? createUserMessage(messageOrString) : messageOrString;
storeMessageInHistory(message);
// If this is the first message in a new session, trigger a refresh immediately
// Only trigger if we're starting a completely new session (no existing messages)
if (messages.length === 0 && chat.messages.length === 0) {
console.log('ChatEngine: New session detected, emitting session-created event');
// Emit event to indicate a new session is being created
window.dispatchEvent(new CustomEvent('session-created'));
}
return originalAppend(message);
},
[originalAppend, storeMessageInHistory, messages.length, chat.messages.length]
);
// Simple token estimation function (roughly 4 characters per token)
const estimateTokens = (text: string): number => {
return Math.ceil(text.length / 4);
};
// Calculate token counts from messages
useEffect(() => {
let inputTokens = 0;
let outputTokens = 0;
messages.forEach((message) => {
const textContent = getTextContent(message);
if (textContent) {
const tokens = estimateTokens(textContent);
if (message.role === 'user') {
inputTokens += tokens;
} else if (message.role === 'assistant') {
outputTokens += tokens;
}
}
});
setLocalInputTokens(inputTokens);
setLocalOutputTokens(outputTokens);
}, [messages]);
// Update chat messages when they change
useEffect(() => {
// @ts-expect-error - TypeScript being overly strict about the return type
setChat((prevChat: ChatType) => ({ ...prevChat, messages }));
}, [messages, setChat]);
// Fetch session metadata to get token count
useEffect(() => {
const fetchSessionTokens = async () => {
try {
const sessionDetails = await fetchSessionDetails(chat.id);
setSessionTokenCount(sessionDetails.metadata.total_tokens || 0);
setSessionInputTokens(sessionDetails.metadata.accumulated_input_tokens || 0);
setSessionOutputTokens(sessionDetails.metadata.accumulated_output_tokens || 0);
} catch (err) {
console.error('Error fetching session token count:', err);
}
};
if (chat.id) {
fetchSessionTokens();
}
}, [chat.id, messages]);
// Update token counts when sessionMetadata changes from the message stream
useEffect(() => {
console.log('Session metadata received:', sessionMetadata);
if (sessionMetadata) {
setSessionTokenCount(sessionMetadata.totalTokens || 0);
setSessionInputTokens(sessionMetadata.accumulatedInputTokens || 0);
setSessionOutputTokens(sessionMetadata.accumulatedOutputTokens || 0);
}
}, [sessionMetadata]);
// Handle submit
const handleSubmit = useCallback(
(combinedTextFromInput: string, onSummaryReset?: () => void) => {
if (combinedTextFromInput.trim()) {
window.electron.startPowerSaveBlocker();
setLastInteractionTime(Date.now());
const userMessage = createUserMessage(combinedTextFromInput.trim());
if (onSummaryReset) {
onSummaryReset();
setTimeout(() => {
append(userMessage);
}, 150);
} else {
append(userMessage);
}
} else {
// If nothing was actually submitted (e.g. empty input and no images pasted)
window.electron.stopPowerSaveBlocker();
}
},
[append]
);
// Handle stopping the message stream
const onStopGoose = useCallback(() => {
stop();
setLastInteractionTime(Date.now());
window.electron.stopPowerSaveBlocker();
// Handle stopping the message stream
const lastMessage = messages[messages.length - 1];
// Check if there are any messages before proceeding
if (!lastMessage) {
return;
}
// check if the last user message has any tool response(s)
const isToolResponse = lastMessage.content.some(
(content): content is ToolResponseMessageContent => content.type == 'toolResponse'
);
// isUserMessage also checks if the message is a toolConfirmationRequest
// check if the last message is a real user's message
if (lastMessage && isUserMessage(lastMessage) && !isToolResponse) {
// Get the text content from the last message before removing it
const textContent = lastMessage.content.find((c): c is TextContent => c.type === 'text');
const textValue = textContent?.text || '';
// Set the text back to the input field
_setInput(textValue);
// Remove the last user message if it's the most recent one
if (messages.length > 1) {
setMessages(messages.slice(0, -1));
} else {
setMessages([]);
}
} else if (!isUserMessage(lastMessage)) {
// the last message was an assistant message
// check if we have any tool requests or tool confirmation requests
const toolRequests: [string, ToolCallResult<ToolCall>][] = lastMessage.content
.filter(
(content): content is ToolRequestMessageContent | ToolConfirmationRequestMessageContent =>
content.type === 'toolRequest' || content.type === 'toolConfirmationRequest'
)
.map((content) => {
if (content.type === 'toolRequest') {
return [content.id, content.toolCall];
} else {
// extract tool call from confirmation
const toolCall: ToolCallResult<ToolCall> = {
status: 'success',
value: {
name: content.toolName,
arguments: content.arguments,
},
};
return [content.id, toolCall];
}
});
if (toolRequests.length !== 0) {
// This means we were interrupted during a tool request
// Create tool responses for all interrupted tool requests
let responseMessage: Message = {
display: true,
sendToLLM: true,
role: 'user',
created: Date.now(),
content: [],
};
const notification = 'Interrupted by the user to make a correction';
// generate a response saying it was interrupted for each tool request
for (const [reqId, _] of toolRequests) {
const toolResponse: ToolResponseMessageContent = {
type: 'toolResponse',
id: reqId,
toolResult: {
status: 'error',
error: notification,
},
};
responseMessage.content.push(toolResponse);
}
// Use an immutable update to add the response message to the messages array
setMessages([...messages, responseMessage]);
}
}
}, [stop, messages, _setInput, setMessages]);
// Filter out standalone tool response messages for rendering
const filteredMessages = useMemo(() => {
return [...ancestorMessages, ...messages].filter((message) => {
// Only filter out when display is explicitly false
if (message.display === false) return false;
// Keep all assistant messages and user messages that aren't just tool responses
if (message.role === 'assistant') return true;
// For user messages, check if they're only tool responses
if (message.role === 'user') {
const hasOnlyToolResponses = message.content.every((c) => c.type === 'toolResponse');
const hasTextContent = message.content.some((c) => c.type === 'text');
const hasToolConfirmation = message.content.every(
(c) => c.type === 'toolConfirmationRequest'
);
// Keep the message if it has text content or tool confirmation or is not just tool responses
return hasTextContent || !hasOnlyToolResponses || hasToolConfirmation;
}
return true;
});
}, [ancestorMessages, messages]);
// Generate command history from filtered messages
const commandHistory = useMemo(() => {
return filteredMessages
.reduce<string[]>((history, message) => {
if (isUserMessage(message)) {
const textContent = message.content.find((c): c is TextContent => c.type === 'text');
const text = textContent?.text?.trim();
if (text) {
history.push(text);
}
}
return history;
}, [])
.reverse();
}, [filteredMessages]);
// Process tool call notifications
const toolCallNotifications = useMemo(() => {
return notifications.reduce((map, item) => {
const key = item.request_id;
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(item);
return map;
}, new Map());
}, [notifications]);
return {
// Core message data
messages,
filteredMessages,
ancestorMessages,
setAncestorMessages,
// Message stream controls
append,
stop,
isLoading,
error,
setMessages,
// Input controls
input: _input,
setInput: _setInput,
handleInputChange: _handleInputChange,
// Event handlers
handleSubmit,
onStopGoose,
// Token and session data
sessionTokenCount,
sessionInputTokens,
sessionOutputTokens,
localInputTokens,
localOutputTokens,
// UI helpers
commandHistory,
toolCallNotifications,
// Stream utilities
updateMessageStreamBody,
sessionMetadata,
// Utilities
isUserMessage,
};
};
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react';
import { useModelAndProvider } from '../components/ModelAndProviderContext';
import { getCostForModel } from '../utils/costDatabase';
import { SessionMetadata } from './useMessageStream';
interface UseCostTrackingProps {
sessionInputTokens: number;
sessionOutputTokens: number;
localInputTokens: number;
localOutputTokens: number;
sessionMetadata?: SessionMetadata | null;
}
export const useCostTracking = ({
sessionInputTokens,
sessionOutputTokens,
localInputTokens,
localOutputTokens,
sessionMetadata,
}: UseCostTrackingProps) => {
const [sessionCosts, setSessionCosts] = useState<{
[key: string]: {
inputTokens: number;
outputTokens: number;
totalCost: number;
};
}>({});
const { currentModel, currentProvider } = useModelAndProvider();
const prevModelRef = useRef<string | undefined>();
const prevProviderRef = useRef<string | undefined>();
// Handle model changes and accumulate costs
useEffect(() => {
if (
prevModelRef.current !== undefined &&
prevProviderRef.current !== undefined &&
(prevModelRef.current !== currentModel || prevProviderRef.current !== currentProvider)
) {
// Model/provider has changed, save the costs for the previous model
const prevKey = `${prevProviderRef.current}/${prevModelRef.current}`;
// Get pricing info for the previous model
const prevCostInfo = getCostForModel(prevProviderRef.current, prevModelRef.current);
if (prevCostInfo) {
const prevInputCost =
(sessionInputTokens || localInputTokens) * (prevCostInfo.input_token_cost || 0);
const prevOutputCost =
(sessionOutputTokens || localOutputTokens) * (prevCostInfo.output_token_cost || 0);
const prevTotalCost = prevInputCost + prevOutputCost;
// Save the accumulated costs for this model
setSessionCosts((prev) => ({
...prev,
[prevKey]: {
inputTokens: sessionInputTokens || localInputTokens,
outputTokens: sessionOutputTokens || localOutputTokens,
totalCost: prevTotalCost,
},
}));
}
console.log(
'Model changed from',
`${prevProviderRef.current}/${prevModelRef.current}`,
'to',
`${currentProvider}/${currentModel}`,
'- saved costs and restored session token counters'
);
}
prevModelRef.current = currentModel || undefined;
prevProviderRef.current = currentProvider || undefined;
}, [
currentModel,
currentProvider,
sessionInputTokens,
sessionOutputTokens,
localInputTokens,
localOutputTokens,
sessionMetadata,
]);
return {
sessionCosts,
};
};
+28
View File
@@ -0,0 +1,28 @@
import { useCallback, useState } from 'react';
export const useFileDrop = () => {
const [droppedFiles, setDroppedFiles] = useState<string[]>([]);
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const files = e.dataTransfer.files;
if (files.length > 0) {
const paths: string[] = [];
for (let i = 0; i < files.length; i++) {
paths.push(window.electron.getPathForFile(files[i]));
}
setDroppedFiles(paths);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
}, []);
return {
droppedFiles,
setDroppedFiles,
handleDrop,
handleDragOver,
};
};
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useMemo, useState } from 'react';
import { createRecipe, Recipe } from '../recipe';
import { Message } from '../types/message';
export const useRecipeManager = (messages: Message[]) => {
const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false);
// Get recipeConfig directly from appConfig
const recipeConfig = useMemo(() => {
return window.appConfig.get('recipeConfig') as Recipe | null;
}, []);
// Pre-fill input with recipe prompt instead of auto-sending it
const initialPrompt = useMemo(() => {
return recipeConfig?.prompt || '';
}, [recipeConfig?.prompt]);
// Listen for make-agent-from-chat event
useEffect(() => {
const handleMakeAgent = async () => {
window.electron.logInfo('Making recipe from chat...');
setIsGeneratingRecipe(true);
try {
// Create recipe directly from chat messages
const createRecipeRequest = {
messages: messages,
title: '',
description: '',
};
const response = await createRecipe(createRecipeRequest);
if (response.error) {
throw new Error(`Failed to create recipe: ${response.error}`);
}
window.electron.logInfo('Created recipe:');
window.electron.logInfo(JSON.stringify(response.recipe, null, 2));
// First, verify the recipe data
if (!response.recipe) {
throw new Error('No recipe data received');
}
// Create a new window for the recipe editor
console.log('Opening recipe editor with config:', response.recipe);
const recipeConfig = {
id: response.recipe.title || 'untitled',
title: response.recipe.title || 'Untitled Recipe',
description: response.recipe.description || '',
instructions: response.recipe.instructions || '',
activities: response.recipe.activities || [],
prompt: response.recipe.prompt || '',
};
window.electron.createChatWindow(
undefined, // query
undefined, // dir
undefined, // version
undefined, // resumeSessionId
recipeConfig, // recipe config
'recipeEditor' // view type
);
window.electron.logInfo('Opening recipe editor window');
} catch (error) {
window.electron.logInfo('Failed to create recipe:');
const errorMessage = error instanceof Error ? error.message : String(error);
window.electron.logInfo(errorMessage);
} finally {
setIsGeneratingRecipe(false);
}
};
window.addEventListener('make-agent-from-chat', handleMakeAgent);
return () => {
window.removeEventListener('make-agent-from-chat', handleMakeAgent);
};
}, [messages]);
return {
recipeConfig,
initialPrompt,
isGeneratingRecipe,
};
};
@@ -0,0 +1,47 @@
import { useEffect } from 'react';
import { generateSessionId } from '../sessions';
import { ChatType } from '../components/hub';
interface UseSessionContinuationProps {
chat: ChatType;
setChat: (chat: ChatType) => void;
summarizedThread: unknown[];
updateMessageStreamBody?: (body: Record<string, unknown>) => void;
}
export const useSessionContinuation = ({
chat,
setChat,
summarizedThread,
updateMessageStreamBody,
}: UseSessionContinuationProps) => {
// Handle session continuation when there's a summarized thread
useEffect(() => {
// If we're in a continuation session, update the chat ID
if (summarizedThread.length > 0) {
const newSessionId = generateSessionId();
// Update the session ID in the chat object
setChat({
...chat,
id: newSessionId!,
title: `Continued from ${chat.id}`,
messageHistoryIndex: summarizedThread.length,
});
// Update the body used by useMessageStream to send future messages to the new session
if (summarizedThread.length > 0 && updateMessageStreamBody) {
updateMessageStreamBody({
session_id: newSessionId,
session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'),
});
}
}
// only update if summarizedThread length changes from 0 -> 1+
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
summarizedThread.length > 0,
]);
};