Goose Simple Compact UX (#4202)
Co-authored-by: David Katz <dkatz@squareup.com>
This commit is contained in:
@@ -20,7 +20,6 @@
|
||||
* - Integrates with multiple custom hooks for separation of concerns:
|
||||
* - useChatEngine: Core chat functionality and API integration
|
||||
* - useRecipeManager: Recipe/agent configuration management
|
||||
* - useSessionContinuation: Session persistence and resumption
|
||||
* - useFileDrop: Drag-and-drop file handling with previews
|
||||
* - useCostTracking: Token usage and cost calculation
|
||||
*
|
||||
@@ -51,12 +50,8 @@ import LoadingGoose from './LoadingGoose';
|
||||
import RecipeActivities from './RecipeActivities';
|
||||
import PopularChatTopics from './PopularChatTopics';
|
||||
import ProgressiveMessageList from './ProgressiveMessageList';
|
||||
import { SessionSummaryModal } from './context_management/SessionSummaryModal';
|
||||
import {
|
||||
ChatContextManagerProvider,
|
||||
useChatContextManager,
|
||||
} from './context_management/ChatContextManager';
|
||||
import { View, ViewOptions } from '../utils/navigationUtils';
|
||||
import { ContextManagerProvider, useContextManager } from './context_management/ContextManager';
|
||||
import { MainPanelLayout } from './Layout/MainPanelLayout';
|
||||
import ChatInput from './ChatInput';
|
||||
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
|
||||
@@ -64,7 +59,6 @@ import { RecipeWarningModal } from './ui/RecipeWarningModal';
|
||||
import ParameterInputModal from './ParameterInputModal';
|
||||
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';
|
||||
@@ -125,21 +119,12 @@ function BaseChatContent({
|
||||
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
|
||||
const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState<string | null>(null);
|
||||
|
||||
const {
|
||||
summaryContent,
|
||||
summarizedThread,
|
||||
isSummaryModalOpen,
|
||||
isLoadingCompaction,
|
||||
resetMessagesWithSummary,
|
||||
closeSummaryModal,
|
||||
updateSummary,
|
||||
} = useChatContextManager();
|
||||
const { isCompacting, handleManualCompaction } = useContextManager();
|
||||
|
||||
// Use shared chat engine
|
||||
const {
|
||||
messages,
|
||||
filteredMessages,
|
||||
ancestorMessages,
|
||||
setAncestorMessages,
|
||||
append,
|
||||
chatState,
|
||||
@@ -156,7 +141,6 @@ function BaseChatContent({
|
||||
localOutputTokens,
|
||||
commandHistory,
|
||||
toolCallNotifications,
|
||||
updateMessageStreamBody,
|
||||
sessionMetadata,
|
||||
isUserMessage,
|
||||
clearError,
|
||||
@@ -173,9 +157,6 @@ function BaseChatContent({
|
||||
if (recipeConfig) {
|
||||
setHasStartedUsingRecipe(true);
|
||||
}
|
||||
|
||||
// Create new session after message is sent if needed
|
||||
createNewSessionIfNeeded();
|
||||
},
|
||||
enableLocalStorage,
|
||||
});
|
||||
@@ -233,14 +214,6 @@ function BaseChatContent({
|
||||
});
|
||||
}, [handleAutoExecution, append, chatState]);
|
||||
|
||||
// Use shared session continuation
|
||||
const { createNewSessionIfNeeded } = useSessionContinuation({
|
||||
chat,
|
||||
setChat,
|
||||
summarizedThread,
|
||||
updateMessageStreamBody,
|
||||
});
|
||||
|
||||
// Use shared file drop
|
||||
const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop();
|
||||
|
||||
@@ -260,7 +233,7 @@ function BaseChatContent({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Empty dependency array means this runs once on mount
|
||||
|
||||
// Handle submit with summary reset support
|
||||
// Handle submit
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const customEvent = e as unknown as CustomEvent;
|
||||
const combinedTextFromInput = customEvent.detail?.value || '';
|
||||
@@ -270,35 +243,12 @@ function BaseChatContent({
|
||||
setHasStartedUsingRecipe(true);
|
||||
}
|
||||
|
||||
const onSummaryReset =
|
||||
summarizedThread.length > 0
|
||||
? () => {
|
||||
resetMessagesWithSummary(
|
||||
messages,
|
||||
setMessages,
|
||||
ancestorMessages,
|
||||
setAncestorMessages,
|
||||
summaryContent
|
||||
);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Call the callback if provided (for Hub to handle navigation)
|
||||
if (onMessageSubmit && combinedTextFromInput.trim()) {
|
||||
onMessageSubmit(combinedTextFromInput);
|
||||
}
|
||||
|
||||
engineHandleSubmit(combinedTextFromInput, onSummaryReset);
|
||||
|
||||
// Auto-scroll to bottom after submitting
|
||||
if (onSummaryReset) {
|
||||
// If we're resetting with summary, delay the scroll a bit more
|
||||
setTimeout(() => {
|
||||
if (scrollRef.current?.scrollToBottom) {
|
||||
scrollRef.current.scrollToBottom();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
engineHandleSubmit(combinedTextFromInput);
|
||||
};
|
||||
|
||||
// Wrapper for append that tracks recipe usage
|
||||
@@ -433,67 +383,49 @@ function BaseChatContent({
|
||||
</SearchView>
|
||||
)}
|
||||
|
||||
{error &&
|
||||
!(error as Error & { isTokenLimitError?: boolean }).isTokenLimitError && (
|
||||
<>
|
||||
<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'}
|
||||
{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>
|
||||
|
||||
{/* Action buttons for all errors including token limit errors */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div
|
||||
className="px-3 py-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 () => {
|
||||
clearError();
|
||||
|
||||
await handleManualCompaction(
|
||||
messages,
|
||||
setMessages,
|
||||
append,
|
||||
setAncestorMessages
|
||||
);
|
||||
}}
|
||||
>
|
||||
Summarize Conversation
|
||||
</div>
|
||||
|
||||
{/* Action buttons for non-token-limit errors */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div
|
||||
className="px-3 py-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 () => {
|
||||
// Create a contextLengthExceeded message similar to token limit errors
|
||||
const contextMessage: Message = {
|
||||
id: `context-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
content: [
|
||||
{
|
||||
type: 'contextLengthExceeded',
|
||||
msg: 'Summarization requested due to error. Creating summary to help resolve the issue.',
|
||||
},
|
||||
],
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
};
|
||||
|
||||
// Add the context message to trigger ContextHandler
|
||||
const updatedMessages = [...messages, contextMessage];
|
||||
setMessages(updatedMessages);
|
||||
|
||||
// Clear the error state since we're handling it with summarization
|
||||
clearError();
|
||||
}}
|
||||
>
|
||||
Summarize Conversation
|
||||
</div>
|
||||
<div
|
||||
className="px-3 py-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
|
||||
className="px-3 py-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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Token limit errors should be handled by ContextHandler, not shown here */}
|
||||
{error &&
|
||||
(error as Error & { isTokenLimitError?: boolean }).isTokenLimitError && <></>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="block h-8" />
|
||||
</>
|
||||
) : showPopularTopics ? (
|
||||
@@ -507,10 +439,10 @@ function BaseChatContent({
|
||||
</ScrollArea>
|
||||
|
||||
{/* Fixed loading indicator at bottom left of chat container */}
|
||||
{chatState !== ChatState.Idle && (
|
||||
{(chatState !== ChatState.Idle || isCompacting) && (
|
||||
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
|
||||
<LoadingGoose
|
||||
message={isLoadingCompaction ? 'summarizing conversation…' : undefined}
|
||||
message={isCompacting ? 'goose is compacting the conversation...' : undefined}
|
||||
chatState={chatState}
|
||||
/>
|
||||
</div>
|
||||
@@ -541,21 +473,13 @@ function BaseChatContent({
|
||||
recipeAccepted={recipeAccepted}
|
||||
initialPrompt={initialPrompt}
|
||||
autoSubmit={autoSubmit}
|
||||
setAncestorMessages={setAncestorMessages}
|
||||
append={append}
|
||||
{...customChatInputProps}
|
||||
/>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
|
||||
<SessionSummaryModal
|
||||
isOpen={isSummaryModalOpen}
|
||||
onClose={closeSummaryModal}
|
||||
onSave={(editedContent) => {
|
||||
updateSummary(editedContent);
|
||||
closeSummaryModal();
|
||||
}}
|
||||
summaryContent={summaryContent}
|
||||
/>
|
||||
|
||||
{/* Recipe Warning Modal */}
|
||||
<RecipeWarningModal
|
||||
isOpen={isRecipeWarningModalOpen}
|
||||
@@ -595,14 +519,16 @@ function BaseChatContent({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No modals needed for the new simplified context manager */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BaseChat(props: BaseChatProps) {
|
||||
return (
|
||||
<ChatContextManagerProvider>
|
||||
<ContextManagerProvider>
|
||||
<BaseChatContent {...props} />
|
||||
</ChatContextManagerProvider>
|
||||
</ContextManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { WaveformVisualizer } from './WaveformVisualizer';
|
||||
import { toastError } from '../toasts';
|
||||
import MentionPopover, { FileItemWithMatch } from './MentionPopover';
|
||||
import { useDictationSettings } from '../hooks/useDictationSettings';
|
||||
import { useChatContextManager } from './context_management/ChatContextManager';
|
||||
import { useContextManager } from './context_management/ContextManager';
|
||||
import { useChatContext } from '../contexts/ChatContext';
|
||||
import { COST_TRACKING_ENABLED } from '../updates';
|
||||
import { CostTracker } from './bottom_menu/CostTracker';
|
||||
@@ -50,7 +50,6 @@ const MAX_IMAGE_SIZE_MB = 5;
|
||||
|
||||
// Constants for token and tool alerts
|
||||
const TOKEN_LIMIT_DEFAULT = 128000; // fallback for custom models that the backend doesn't know about
|
||||
const TOKEN_WARNING_THRESHOLD = 0.8; // warning shows at 80% of the token limit
|
||||
const TOOLS_MAX_SUGGESTED = 60; // max number of tools before we show a warning
|
||||
|
||||
interface ModelLimit {
|
||||
@@ -85,6 +84,8 @@ interface ChatInputProps {
|
||||
recipeAccepted?: boolean;
|
||||
initialPrompt?: string;
|
||||
autoSubmit: boolean;
|
||||
setAncestorMessages?: (messages: Message[]) => void;
|
||||
append?: (message: Message) => void;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
@@ -108,6 +109,8 @@ export default function ChatInput({
|
||||
recipeAccepted,
|
||||
initialPrompt,
|
||||
autoSubmit = false,
|
||||
append,
|
||||
setAncestorMessages,
|
||||
}: ChatInputProps) {
|
||||
const [_value, setValue] = useState(initialValue);
|
||||
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
|
||||
@@ -129,7 +132,7 @@ export default function ChatInput({
|
||||
null
|
||||
) as React.RefObject<HTMLDivElement>;
|
||||
const toolCount = useToolCount();
|
||||
const { isLoadingCompaction, handleManualCompaction } = useChatContextManager();
|
||||
const { isCompacting, handleManualCompaction } = useContextManager();
|
||||
const { getProviders, read } = useConfig();
|
||||
const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider();
|
||||
const [tokenLimit, setTokenLimit] = useState<number>(TOKEN_LIMIT_DEFAULT);
|
||||
@@ -511,55 +514,27 @@ export default function ChatInput({
|
||||
useEffect(() => {
|
||||
clearAlerts();
|
||||
|
||||
// Always show token alerts if we have loaded the real token limit and have tokens
|
||||
if (isTokenLimitLoaded && tokenLimit && numTokens && numTokens > 0) {
|
||||
if (numTokens >= tokenLimit) {
|
||||
// Only show error alert when limit reached
|
||||
addAlert({
|
||||
type: AlertType.Error,
|
||||
message: `Token limit reached (${numTokens.toLocaleString()}/${tokenLimit.toLocaleString()}) \n You've reached the model's conversation limit. The session will be saved — copy anything important and start a new one to continue.`,
|
||||
autoShow: true, // Auto-show token limit errors
|
||||
});
|
||||
} else if (numTokens >= tokenLimit * TOKEN_WARNING_THRESHOLD) {
|
||||
// Only show warning alert when approaching limit
|
||||
addAlert({
|
||||
type: AlertType.Warning,
|
||||
message: `Approaching token limit (${numTokens.toLocaleString()}/${tokenLimit.toLocaleString()}) \n You're reaching the model's conversation limit. Consider compacting the conversation to continue.`,
|
||||
autoShow: true, // Auto-show token limit warnings
|
||||
});
|
||||
} else {
|
||||
// Show info alert with summarize button
|
||||
addAlert({
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
progress: {
|
||||
current: numTokens,
|
||||
total: tokenLimit,
|
||||
},
|
||||
showSummarizeButton: true,
|
||||
onSummarize: () => {
|
||||
handleManualCompaction(messages, setMessages);
|
||||
},
|
||||
summarizeIcon: <ScrollText size={12} />,
|
||||
});
|
||||
}
|
||||
} else if (isTokenLimitLoaded && tokenLimit) {
|
||||
// Always show context window info even when no tokens are present (start of conversation)
|
||||
// Show alert when either there is registered token usage, or we know the limit
|
||||
if ((numTokens && numTokens > 0) || (isTokenLimitLoaded && tokenLimit)) {
|
||||
// in these conditions we want it to be present but disabled
|
||||
const compactButtonDisabled = !numTokens || isCompacting;
|
||||
|
||||
addAlert({
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
progress: {
|
||||
current: 0,
|
||||
current: numTokens || 0,
|
||||
total: tokenLimit,
|
||||
},
|
||||
showSummarizeButton: messages.length > 0,
|
||||
onSummarize:
|
||||
messages.length > 0
|
||||
? () => {
|
||||
handleManualCompaction(messages, setMessages);
|
||||
}
|
||||
: undefined,
|
||||
summarizeIcon: messages.length > 0 ? <ScrollText size={12} /> : undefined,
|
||||
showCompactButton: true,
|
||||
compactButtonDisabled,
|
||||
onCompact: () => {
|
||||
// Hide the alert popup by dispatching a custom event that the popover can listen to
|
||||
// Importantly, this leaves the alert so the dot still shows up, but hides the popover
|
||||
window.dispatchEvent(new CustomEvent('hide-alert-popover'));
|
||||
handleManualCompaction(messages, setMessages, append, setAncestorMessages);
|
||||
},
|
||||
compactIcon: <ScrollText size={12} />,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -577,7 +552,7 @@ export default function ChatInput({
|
||||
}
|
||||
// We intentionally omit setView as it shouldn't trigger a re-render of alerts
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [numTokens, toolCount, tokenLimit, isTokenLimitLoaded, addAlert, clearAlerts]);
|
||||
}, [numTokens, toolCount, tokenLimit, isTokenLimitLoaded, addAlert, isCompacting, clearAlerts]);
|
||||
|
||||
// Cleanup effect for component unmount - prevent memory leaks
|
||||
useEffect(() => {
|
||||
@@ -1086,7 +1061,7 @@ export default function ChatInput({
|
||||
|
||||
const canSubmit =
|
||||
!isLoading &&
|
||||
!isLoadingCompaction &&
|
||||
!isCompacting &&
|
||||
agentIsReady &&
|
||||
(displayValue.trim() ||
|
||||
pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) ||
|
||||
@@ -1101,7 +1076,7 @@ export default function ChatInput({
|
||||
e.preventDefault();
|
||||
const canSubmit =
|
||||
!isLoading &&
|
||||
!isLoadingCompaction &&
|
||||
!isCompacting &&
|
||||
agentIsReady &&
|
||||
(displayValue.trim() ||
|
||||
pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) ||
|
||||
@@ -1380,7 +1355,7 @@ export default function ChatInput({
|
||||
isAnyDroppedFileLoading ||
|
||||
isRecording ||
|
||||
isTranscribing ||
|
||||
isLoadingCompaction ||
|
||||
isCompacting ||
|
||||
!agentIsReady
|
||||
}
|
||||
className={`rounded-full px-10 py-2 flex items-center gap-2 ${
|
||||
@@ -1389,7 +1364,7 @@ export default function ChatInput({
|
||||
isAnyDroppedFileLoading ||
|
||||
isRecording ||
|
||||
isTranscribing ||
|
||||
isLoadingCompaction ||
|
||||
isCompacting ||
|
||||
!agentIsReady
|
||||
? 'bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600'
|
||||
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600 hover:cursor-pointer'
|
||||
@@ -1402,8 +1377,8 @@ export default function ChatInput({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{isLoadingCompaction
|
||||
? 'Summarizing conversation...'
|
||||
{isCompacting
|
||||
? 'Compacting conversation...'
|
||||
: isAnyImageLoading
|
||||
? 'Waiting for images to save...'
|
||||
: isAnyDroppedFileLoading
|
||||
|
||||
@@ -18,8 +18,8 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Message } from '../types/message';
|
||||
import GooseMessage from './GooseMessage';
|
||||
import UserMessage from './UserMessage';
|
||||
import { ContextHandler } from './context_management/ContextHandler';
|
||||
import { useChatContextManager } from './context_management/ChatContextManager';
|
||||
import { CompactionMarker } from './context_management/CompactionMarker';
|
||||
import { useContextManager } from './context_management/ContextManager';
|
||||
import { NotificationEvent } from '../hooks/useMessageStream';
|
||||
import LoadingGoose from './LoadingGoose';
|
||||
|
||||
@@ -66,20 +66,15 @@ export default function ProgressiveMessageList({
|
||||
message.content.every((c) => c.type === 'toolResponse');
|
||||
|
||||
// Try to use context manager, but don't require it for session history
|
||||
let hasContextHandlerContent: ((message: Message) => boolean) | undefined;
|
||||
let getContextHandlerType:
|
||||
| ((message: Message) => 'contextLengthExceeded' | 'summarizationRequested')
|
||||
| undefined;
|
||||
let hasCompactionMarker: ((message: Message) => boolean) | undefined;
|
||||
|
||||
try {
|
||||
const contextManager = useChatContextManager();
|
||||
hasContextHandlerContent = contextManager.hasContextHandlerContent;
|
||||
getContextHandlerType = contextManager.getContextHandlerType;
|
||||
const contextManager = useContextManager();
|
||||
hasCompactionMarker = contextManager.hasCompactionMarker;
|
||||
} catch {
|
||||
// Context manager not available (e.g., in session history view)
|
||||
// This is fine, we'll just skip context handler functionality
|
||||
hasContextHandlerContent = undefined;
|
||||
getContextHandlerType = undefined;
|
||||
// This is fine, we'll just skip compaction marker functionality
|
||||
hasCompactionMarker = undefined;
|
||||
}
|
||||
|
||||
// Simple progressive loading - start immediately when component mounts if needed
|
||||
@@ -183,14 +178,8 @@ export default function ProgressiveMessageList({
|
||||
>
|
||||
{isUser ? (
|
||||
<>
|
||||
{hasContextHandlerContent && 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)}
|
||||
/>
|
||||
{hasCompactionMarker && hasCompactionMarker(message) ? (
|
||||
<CompactionMarker message={message} />
|
||||
) : (
|
||||
!hasOnlyToolResponses(message) && (
|
||||
<UserMessage message={message} onMessageUpdate={onMessageUpdate} />
|
||||
@@ -199,14 +188,8 @@ export default function ProgressiveMessageList({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{hasContextHandlerContent && 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)}
|
||||
/>
|
||||
{hasCompactionMarker && hasCompactionMarker(message) ? (
|
||||
<CompactionMarker message={message} />
|
||||
) : (
|
||||
<GooseMessage
|
||||
messageHistoryIndex={chat.messageHistoryIndex}
|
||||
@@ -244,8 +227,7 @@ export default function ProgressiveMessageList({
|
||||
toolCallNotifications,
|
||||
isStreamingMessage,
|
||||
onMessageUpdate,
|
||||
hasContextHandlerContent,
|
||||
getContextHandlerType,
|
||||
hasCompactionMarker
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,7 @@ const alertIcons: Record<AlertType, React.ReactNode> = {
|
||||
interface AlertBoxProps {
|
||||
alert: Alert;
|
||||
className?: string;
|
||||
compactButtonEnabled?: boolean;
|
||||
}
|
||||
|
||||
const alertStyles: Record<AlertType, string> = {
|
||||
@@ -60,17 +61,23 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => {
|
||||
: alert.progress!.total}
|
||||
</span>
|
||||
</div>
|
||||
{alert.showSummarizeButton && alert.onSummarize && (
|
||||
{alert.showCompactButton && alert.onCompact && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
alert.onSummarize!();
|
||||
alert.onCompact!();
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-[11px] hover:opacity-80 cursor-pointer outline-none mt-1"
|
||||
disabled={alert.compactButtonDisabled}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[11px] outline-none mt-1',
|
||||
alert.compactButtonDisabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:opacity-80 cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{alert.summarizeIcon}
|
||||
<span>Summarize now</span>
|
||||
{alert.compactIcon}
|
||||
<span>Compact now</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { AlertBox } from '../AlertBox';
|
||||
import { Alert, AlertType } from '../types';
|
||||
|
||||
describe('AlertBox', () => {
|
||||
const mockOnCompact = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render info alert with message', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Test info message',
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
expect(screen.getByText('Test info message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render warning alert with correct styling', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Warning,
|
||||
message: 'Test warning message',
|
||||
};
|
||||
|
||||
const { container } = render(<AlertBox alert={alert} />);
|
||||
const alertElement = container.querySelector('.bg-\\[\\#cc4b03\\]');
|
||||
|
||||
expect(alertElement).toBeInTheDocument();
|
||||
expect(screen.getByText('Test warning message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render error alert with correct styling', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Error,
|
||||
message: 'Test error message',
|
||||
};
|
||||
|
||||
const { container } = render(<AlertBox alert={alert} />);
|
||||
const alertElement = container.querySelector('.bg-\\[\\#d7040e\\]');
|
||||
|
||||
expect(alertElement).toBeInTheDocument();
|
||||
expect(screen.getByText('Test error message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Test message',
|
||||
};
|
||||
|
||||
const { container } = render(<AlertBox alert={alert} className="custom-class" />);
|
||||
const alertElement = container.firstChild as HTMLElement;
|
||||
|
||||
expect(alertElement).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Progress Bar', () => {
|
||||
it('should render progress bar when progress is provided', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
progress: {
|
||||
current: 50,
|
||||
total: 100,
|
||||
},
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
expect(screen.getByText('50')).toBeInTheDocument();
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
|
||||
// Check progress bar exists
|
||||
const progressDots = screen.getByText('Context window').parentElement?.parentElement?.querySelectorAll('.h-\\[2px\\]');
|
||||
expect(progressDots).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle zero current value', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
progress: {
|
||||
current: 0,
|
||||
total: 100,
|
||||
},
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle 100% progress', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
progress: {
|
||||
current: 100,
|
||||
total: 100,
|
||||
},
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
// Use getAllByText since there are multiple "100" elements (current and total)
|
||||
const hundredElements = screen.getAllByText('100');
|
||||
expect(hundredElements).toHaveLength(2); // One for current, one for total
|
||||
expect(screen.getByText('100%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should format large numbers with k suffix', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
progress: {
|
||||
current: 1500,
|
||||
total: 10000,
|
||||
},
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
expect(screen.getByText('1.5k')).toBeInTheDocument();
|
||||
expect(screen.getByText('15%')).toBeInTheDocument();
|
||||
expect(screen.getByText('10k')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle progress over 100%', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Warning,
|
||||
message: 'Context window',
|
||||
progress: {
|
||||
current: 150,
|
||||
total: 100,
|
||||
},
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
expect(screen.getByText('150')).toBeInTheDocument();
|
||||
expect(screen.getByText('150%')).toBeInTheDocument();
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Compact Button', () => {
|
||||
it('should render compact button when showCompactButton is true', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
progress: { current: 50, total: 100 },
|
||||
showCompactButton: true,
|
||||
onCompact: mockOnCompact,
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
expect(screen.getByText('Compact now')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render compact button with custom icon', () => {
|
||||
const CompactIcon = () => <span data-testid="compact-icon">📦</span>;
|
||||
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
progress: { current: 50, total: 100 },
|
||||
showCompactButton: true,
|
||||
onCompact: mockOnCompact,
|
||||
compactIcon: <CompactIcon />,
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
expect(screen.getByTestId('compact-icon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Compact now')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onCompact when compact button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
progress: { current: 50, total: 100 },
|
||||
showCompactButton: true,
|
||||
onCompact: mockOnCompact,
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
const compactButton = screen.getByText('Compact now');
|
||||
await user.click(compactButton);
|
||||
|
||||
expect(mockOnCompact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should prevent event propagation when compact button is clicked', () => {
|
||||
const mockParentClick = vi.fn();
|
||||
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
progress: { current: 50, total: 100 },
|
||||
showCompactButton: true,
|
||||
onCompact: mockOnCompact,
|
||||
};
|
||||
|
||||
render(
|
||||
<div onClick={mockParentClick}>
|
||||
<AlertBox alert={alert} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const compactButton = screen.getByText('Compact now');
|
||||
fireEvent.click(compactButton);
|
||||
|
||||
expect(mockOnCompact).toHaveBeenCalledTimes(1);
|
||||
expect(mockParentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not render compact button when showCompactButton is false', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
progress: { current: 50, total: 100 },
|
||||
showCompactButton: false,
|
||||
onCompact: mockOnCompact,
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
expect(screen.queryByText('Compact now')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render compact button when onCompact is not provided', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
progress: { current: 50, total: 100 },
|
||||
showCompactButton: true,
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
expect(screen.queryByText('Compact now')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combined Features', () => {
|
||||
it('should render progress bar and compact button together', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
progress: {
|
||||
current: 75,
|
||||
total: 100,
|
||||
},
|
||||
showCompactButton: true,
|
||||
onCompact: mockOnCompact,
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
expect(screen.getByText('75')).toBeInTheDocument();
|
||||
expect(screen.getByText('75%')).toBeInTheDocument();
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
expect(screen.getByText('Compact now')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle multiline messages', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Warning,
|
||||
message: 'Line 1\nLine 2\nLine 3',
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
// Use a function matcher to handle the whitespace-pre-line rendering
|
||||
expect(screen.getByText((content) => content.includes('Line 1') && content.includes('Line 2') && content.includes('Line 3'))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty message', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: '',
|
||||
};
|
||||
|
||||
const { container } = render(<AlertBox alert={alert} />);
|
||||
|
||||
// Should still render the alert container
|
||||
const alertElement = container.querySelector('.flex.flex-col.gap-2');
|
||||
expect(alertElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle progress with zero total', () => {
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
progress: {
|
||||
current: 10,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
expect(screen.getByText('10')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
// Progress percentage would be Infinity, but it should still render
|
||||
expect(screen.getByText('Infinity%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useAlerts } from '../useAlerts';
|
||||
import { AlertType } from '../types';
|
||||
|
||||
describe('useAlerts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should start with empty alerts array', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
expect(result.current.alerts).toEqual([]);
|
||||
expect(typeof result.current.addAlert).toBe('function');
|
||||
expect(typeof result.current.clearAlerts).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Adding Alerts', () => {
|
||||
it('should add a single alert', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
const newAlert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Test alert',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.addAlert(newAlert);
|
||||
});
|
||||
|
||||
expect(result.current.alerts).toHaveLength(1);
|
||||
expect(result.current.alerts[0]).toMatchObject(newAlert);
|
||||
});
|
||||
|
||||
it('should add multiple alerts', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
const alert1 = { type: AlertType.Info, message: 'First alert' };
|
||||
const alert2 = { type: AlertType.Warning, message: 'Second alert' };
|
||||
const alert3 = { type: AlertType.Error, message: 'Third alert' };
|
||||
|
||||
act(() => {
|
||||
result.current.addAlert(alert1);
|
||||
result.current.addAlert(alert2);
|
||||
result.current.addAlert(alert3);
|
||||
});
|
||||
|
||||
expect(result.current.alerts).toHaveLength(3);
|
||||
expect(result.current.alerts[0]).toMatchObject(alert1);
|
||||
expect(result.current.alerts[1]).toMatchObject(alert2);
|
||||
expect(result.current.alerts[2]).toMatchObject(alert3);
|
||||
});
|
||||
|
||||
it('should add alerts with all optional properties', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
const complexAlert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Complex alert',
|
||||
progress: { current: 50, total: 100 },
|
||||
showCompactButton: true,
|
||||
onCompact: vi.fn(),
|
||||
compactIcon: <span>Icon</span>,
|
||||
autoShow: true,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.addAlert(complexAlert);
|
||||
});
|
||||
|
||||
expect(result.current.alerts).toHaveLength(1);
|
||||
expect(result.current.alerts[0]).toMatchObject(complexAlert);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Clearing Alerts', () => {
|
||||
it('should clear all alerts', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
// Add some alerts first
|
||||
act(() => {
|
||||
result.current.addAlert({ type: AlertType.Info, message: 'Alert 1' });
|
||||
result.current.addAlert({ type: AlertType.Warning, message: 'Alert 2' });
|
||||
result.current.addAlert({ type: AlertType.Error, message: 'Alert 3' });
|
||||
});
|
||||
|
||||
expect(result.current.alerts).toHaveLength(3);
|
||||
|
||||
// Clear all alerts
|
||||
act(() => {
|
||||
result.current.clearAlerts();
|
||||
});
|
||||
|
||||
expect(result.current.alerts).toHaveLength(0);
|
||||
expect(result.current.alerts).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle clearing when no alerts exist', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
expect(result.current.alerts).toHaveLength(0);
|
||||
|
||||
// Should not throw error
|
||||
act(() => {
|
||||
result.current.clearAlerts();
|
||||
});
|
||||
|
||||
expect(result.current.alerts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alert Management Patterns', () => {
|
||||
it('should handle rapid add and clear operations', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
// Rapid operations
|
||||
act(() => {
|
||||
result.current.addAlert({ type: AlertType.Info, message: 'Alert 1' });
|
||||
result.current.addAlert({ type: AlertType.Warning, message: 'Alert 2' });
|
||||
result.current.clearAlerts();
|
||||
result.current.addAlert({ type: AlertType.Error, message: 'Alert 3' });
|
||||
});
|
||||
|
||||
expect(result.current.alerts).toHaveLength(1);
|
||||
expect(result.current.alerts[0].message).toBe('Alert 3');
|
||||
});
|
||||
|
||||
it('should maintain alert order', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
const alerts = [
|
||||
{ type: AlertType.Info, message: 'First' },
|
||||
{ type: AlertType.Warning, message: 'Second' },
|
||||
{ type: AlertType.Error, message: 'Third' },
|
||||
{ type: AlertType.Info, message: 'Fourth' },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
alerts.forEach(alert => result.current.addAlert(alert));
|
||||
});
|
||||
|
||||
expect(result.current.alerts).toHaveLength(4);
|
||||
alerts.forEach((alert, index) => {
|
||||
expect(result.current.alerts[index].message).toBe(alert.message);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle duplicate alerts', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
const duplicateAlert = { type: AlertType.Info, message: 'Duplicate alert' };
|
||||
|
||||
act(() => {
|
||||
result.current.addAlert(duplicateAlert);
|
||||
result.current.addAlert(duplicateAlert);
|
||||
result.current.addAlert(duplicateAlert);
|
||||
});
|
||||
|
||||
// Should allow duplicates
|
||||
expect(result.current.alerts).toHaveLength(3);
|
||||
result.current.alerts.forEach(alert => {
|
||||
expect(alert.message).toBe('Duplicate alert');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alert Types', () => {
|
||||
it('should handle all alert types', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
const alertTypes = [
|
||||
{ type: AlertType.Info, message: 'Info alert' },
|
||||
{ type: AlertType.Warning, message: 'Warning alert' },
|
||||
{ type: AlertType.Error, message: 'Error alert' },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
alertTypes.forEach(alert => result.current.addAlert(alert));
|
||||
});
|
||||
|
||||
expect(result.current.alerts).toHaveLength(3);
|
||||
expect(result.current.alerts[0].type).toBe(AlertType.Info);
|
||||
expect(result.current.alerts[1].type).toBe(AlertType.Warning);
|
||||
expect(result.current.alerts[2].type).toBe(AlertType.Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Progress Alerts', () => {
|
||||
it('should handle alerts with progress', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
const progressAlert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Loading...',
|
||||
progress: { current: 25, total: 100 },
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.addAlert(progressAlert);
|
||||
});
|
||||
|
||||
expect(result.current.alerts[0].progress).toEqual({ current: 25, total: 100 });
|
||||
});
|
||||
|
||||
it('should handle progress updates by replacing alerts', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
// Add initial progress alert
|
||||
act(() => {
|
||||
result.current.addAlert({
|
||||
type: AlertType.Info,
|
||||
message: 'Loading...',
|
||||
progress: { current: 25, total: 100 },
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.alerts[0].progress?.current).toBe(25);
|
||||
|
||||
// Clear and add updated progress
|
||||
act(() => {
|
||||
result.current.clearAlerts();
|
||||
result.current.addAlert({
|
||||
type: AlertType.Info,
|
||||
message: 'Loading...',
|
||||
progress: { current: 75, total: 100 },
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.alerts).toHaveLength(1);
|
||||
expect(result.current.alerts[0].progress?.current).toBe(75);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Compact Button Alerts', () => {
|
||||
it('should handle alerts with compact functionality', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
const mockOnCompact = vi.fn();
|
||||
const compactAlert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window full',
|
||||
showCompactButton: true,
|
||||
onCompact: mockOnCompact,
|
||||
compactIcon: <span>📦</span>,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.addAlert(compactAlert);
|
||||
});
|
||||
|
||||
const alert = result.current.alerts[0];
|
||||
expect(alert.showCompactButton).toBe(true);
|
||||
expect(alert.onCompact).toBe(mockOnCompact);
|
||||
expect(alert.compactIcon).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-show Alerts', () => {
|
||||
it('should handle autoShow property', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
const autoShowAlert = {
|
||||
type: AlertType.Error,
|
||||
message: 'Critical error',
|
||||
autoShow: true,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.addAlert(autoShowAlert);
|
||||
});
|
||||
|
||||
expect(result.current.alerts[0].autoShow).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle alerts without autoShow property', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
const regularAlert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Regular alert',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.addAlert(regularAlert);
|
||||
});
|
||||
|
||||
expect(result.current.alerts[0].autoShow).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty message', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
act(() => {
|
||||
result.current.addAlert({
|
||||
type: AlertType.Info,
|
||||
message: '',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.alerts).toHaveLength(1);
|
||||
expect(result.current.alerts[0].message).toBe('');
|
||||
});
|
||||
|
||||
it('should handle very long messages', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
const longMessage = 'A'.repeat(1000);
|
||||
|
||||
act(() => {
|
||||
result.current.addAlert({
|
||||
type: AlertType.Info,
|
||||
message: longMessage,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.alerts[0].message).toBe(longMessage);
|
||||
});
|
||||
|
||||
it('should handle special characters in messages', () => {
|
||||
const { result } = renderHook(() => useAlerts());
|
||||
|
||||
const specialMessage = '🚨 Alert with émojis and spëcial chars! @#$%^&*()';
|
||||
|
||||
act(() => {
|
||||
result.current.addAlert({
|
||||
type: AlertType.Warning,
|
||||
message: specialMessage,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.alerts[0].message).toBe(specialMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,8 @@ export interface Alert {
|
||||
current: number;
|
||||
total: number;
|
||||
};
|
||||
showSummarizeButton?: boolean;
|
||||
onSummarize?: () => void;
|
||||
summarizeIcon?: React.ReactNode;
|
||||
showCompactButton?: boolean;
|
||||
compactButtonDisabled?: boolean;
|
||||
onCompact?: () => void;
|
||||
compactIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
|
||||
useEffect(() => {
|
||||
if (alerts.length > 0) {
|
||||
setShouldShowIndicator(true);
|
||||
} else {
|
||||
setShouldShowIndicator(false);
|
||||
}
|
||||
}, [alerts.length]);
|
||||
|
||||
@@ -148,6 +150,27 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Listen for custom event to hide the popover
|
||||
useEffect(() => {
|
||||
const handleHidePopover = () => {
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
setWasAutoShown(false);
|
||||
setIsHovered(false);
|
||||
// Clear any pending hide timer
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('hide-alert-popover', handleHidePopover);
|
||||
return () => {
|
||||
window.removeEventListener('hide-alert-popover', handleHidePopover);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Use shouldShowIndicator instead of alerts.length for rendering decision
|
||||
if (!shouldShowIndicator) {
|
||||
return null;
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import { ScrollText } from 'lucide-react';
|
||||
import { Message } from '../../types/message';
|
||||
import {
|
||||
manageContextFromBackend,
|
||||
convertApiMessageToFrontendMessage,
|
||||
createSummarizationRequestMessage,
|
||||
} from './index';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
// Define the context management interface
|
||||
interface ChatContextManagerState {
|
||||
summaryContent: string;
|
||||
summarizedThread: Message[];
|
||||
isSummaryModalOpen: boolean;
|
||||
isLoadingCompaction: boolean;
|
||||
errorLoadingSummary: boolean;
|
||||
preparingManualSummary: boolean;
|
||||
isConfirmationOpen: boolean;
|
||||
pendingCompactionData: { messages: Message[]; setMessages: (messages: Message[]) => void } | null;
|
||||
}
|
||||
|
||||
interface ChatContextManagerActions {
|
||||
updateSummary: (newSummaryContent: string) => void;
|
||||
resetMessagesWithSummary: (
|
||||
messages: Message[],
|
||||
setMessages: (messages: Message[]) => void,
|
||||
ancestorMessages: Message[],
|
||||
setAncestorMessages: (messages: Message[]) => void,
|
||||
summaryContent: string
|
||||
) => void;
|
||||
openSummaryModal: () => void;
|
||||
closeSummaryModal: () => void;
|
||||
hasContextHandlerContent: (message: Message) => boolean;
|
||||
hasContextLengthExceededContent: (message: Message) => boolean;
|
||||
hasSummarizationRequestedContent: (message: Message) => boolean;
|
||||
getContextHandlerType: (message: Message) => 'contextLengthExceeded' | 'summarizationRequested';
|
||||
handleContextLengthExceeded: (messages: Message[]) => Promise<void>;
|
||||
handleManualCompaction: (messages: Message[], setMessages: (messages: Message[]) => void) => void;
|
||||
}
|
||||
|
||||
// Create the context
|
||||
const ChatContextManagerContext = createContext<
|
||||
(ChatContextManagerState & ChatContextManagerActions) | undefined
|
||||
>(undefined);
|
||||
|
||||
// Create the provider component
|
||||
export const ChatContextManagerProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [summaryContent, setSummaryContent] = useState<string>('');
|
||||
const [summarizedThread, setSummarizedThread] = useState<Message[]>([]);
|
||||
const [isSummaryModalOpen, setIsSummaryModalOpen] = useState<boolean>(false);
|
||||
const [isLoadingCompaction, setIsLoadingCompaction] = useState<boolean>(false);
|
||||
const [errorLoadingSummary, setErrorLoadingSummary] = useState<boolean>(false);
|
||||
const [preparingManualSummary, setPreparingManualSummary] = useState<boolean>(false);
|
||||
const [isConfirmationOpen, setIsConfirmationOpen] = useState<boolean>(false);
|
||||
const [pendingCompactionData, setPendingCompactionData] = useState<{
|
||||
messages: Message[];
|
||||
setMessages: (messages: Message[]) => void;
|
||||
} | null>(null);
|
||||
|
||||
const handleContextLengthExceeded = async (messages: Message[]): Promise<void> => {
|
||||
setIsLoadingCompaction(true);
|
||||
setErrorLoadingSummary(false);
|
||||
setPreparingManualSummary(true);
|
||||
|
||||
try {
|
||||
// 2. Now get the summary from the backend
|
||||
const summaryResponse = await manageContextFromBackend({
|
||||
messages: messages,
|
||||
manageAction: 'summarize',
|
||||
});
|
||||
|
||||
// Convert API messages to frontend messages
|
||||
const convertedMessages = summaryResponse.messages.map(
|
||||
(apiMessage) => convertApiMessageToFrontendMessage(apiMessage, false, true) // do not show to user but send to llm
|
||||
);
|
||||
|
||||
// Extract summary from the first message
|
||||
const summaryMessage = convertedMessages[0].content[0];
|
||||
if (summaryMessage.type === 'text') {
|
||||
const summary = summaryMessage.text;
|
||||
setSummaryContent(summary);
|
||||
setSummarizedThread(convertedMessages);
|
||||
}
|
||||
|
||||
setIsLoadingCompaction(false);
|
||||
} catch (err) {
|
||||
console.error('Error handling context length exceeded:', err);
|
||||
setErrorLoadingSummary(true);
|
||||
setIsLoadingCompaction(false);
|
||||
} finally {
|
||||
setPreparingManualSummary(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualCompaction = (
|
||||
messages: Message[],
|
||||
setMessages: (messages: Message[]) => void
|
||||
): void => {
|
||||
// Store the pending compaction data and open confirmation dialog
|
||||
setPendingCompactionData({ messages, setMessages });
|
||||
setIsConfirmationOpen(true);
|
||||
};
|
||||
|
||||
const handleCompactionConfirm = () => {
|
||||
if (!pendingCompactionData) return;
|
||||
|
||||
const { messages, setMessages } = pendingCompactionData;
|
||||
|
||||
// add some messages to the message thread
|
||||
// these messages will be filtered out in chat view
|
||||
// but they will also be what allows us to render some text in the chatview itself, similar to CLE events
|
||||
const summarizationRequest = createSummarizationRequestMessage(
|
||||
messages,
|
||||
'Summarize the session and begin a new one'
|
||||
);
|
||||
|
||||
// add the message to the message thread
|
||||
setMessages([...messages, summarizationRequest]);
|
||||
|
||||
setIsConfirmationOpen(false);
|
||||
setPendingCompactionData(null);
|
||||
};
|
||||
|
||||
const handleCompactionCancel = () => {
|
||||
setIsConfirmationOpen(false);
|
||||
setPendingCompactionData(null);
|
||||
};
|
||||
|
||||
const updateSummary = (newSummaryContent: string) => {
|
||||
// Update the summary content
|
||||
setSummaryContent(newSummaryContent);
|
||||
|
||||
// Update the thread if it exists
|
||||
if (summarizedThread.length > 0) {
|
||||
// Create a deep copy of the thread
|
||||
const updatedThread = [...summarizedThread];
|
||||
|
||||
// Create a copy of the first message
|
||||
const firstMessage = { ...updatedThread[0] };
|
||||
|
||||
// Create a copy of the content array
|
||||
const updatedContent = [...firstMessage.content];
|
||||
|
||||
// Update the summary text in the first content item
|
||||
if (updatedContent[0] && updatedContent[0].type === 'text') {
|
||||
updatedContent[0] = {
|
||||
...updatedContent[0],
|
||||
text: newSummaryContent,
|
||||
};
|
||||
}
|
||||
|
||||
// Update the message with the new content
|
||||
firstMessage.content = updatedContent;
|
||||
updatedThread[0] = firstMessage;
|
||||
|
||||
// Update the thread
|
||||
setSummarizedThread(updatedThread);
|
||||
}
|
||||
};
|
||||
|
||||
const resetMessagesWithSummary = (
|
||||
messages: Message[],
|
||||
setMessages: (messages: Message[]) => void,
|
||||
ancestorMessages: Message[],
|
||||
setAncestorMessages: (messages: Message[]) => void,
|
||||
summaryContent: string
|
||||
) => {
|
||||
// Create a copy of the summarized thread
|
||||
const updatedSummarizedThread = [...summarizedThread];
|
||||
|
||||
// Make sure there's at least one message in the summarized thread
|
||||
if (updatedSummarizedThread.length > 0) {
|
||||
// Get the first message
|
||||
const firstMessage = { ...updatedSummarizedThread[0] };
|
||||
|
||||
// Make a copy of the content array
|
||||
const contentCopy = [...firstMessage.content];
|
||||
|
||||
// Assuming the first content item is of type TextContent
|
||||
if (contentCopy.length > 0 && 'text' in contentCopy[0]) {
|
||||
// Update the text with the new summary content
|
||||
contentCopy[0] = {
|
||||
...contentCopy[0],
|
||||
text: summaryContent,
|
||||
};
|
||||
|
||||
// Update the first message with the new content
|
||||
firstMessage.content = contentCopy;
|
||||
|
||||
// Update the first message in the thread
|
||||
updatedSummarizedThread[0] = firstMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// Update metadata for the summarized thread
|
||||
const finalUpdatedThread = updatedSummarizedThread.map((msg, index) => ({
|
||||
...msg,
|
||||
display: index === 0, // First message has display: true, others false
|
||||
sendToLLM: true, // All messages have sendToLLM: true
|
||||
}));
|
||||
|
||||
// Update the messages state
|
||||
setMessages(finalUpdatedThread);
|
||||
|
||||
// If ancestorMessages already has items, extend it instead of replacing it
|
||||
if (ancestorMessages.length > 0) {
|
||||
// Convert current messages to ancestor format
|
||||
const newAncestorMessages = messages.map((msg) => ({
|
||||
...msg,
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
}));
|
||||
|
||||
// Append new ancestor messages to existing ones
|
||||
setAncestorMessages([...ancestorMessages, ...newAncestorMessages]);
|
||||
} else {
|
||||
// Initial set of ancestor messages
|
||||
const newAncestorMessages = messages.map((msg) => ({
|
||||
...msg,
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
}));
|
||||
|
||||
setAncestorMessages(newAncestorMessages);
|
||||
}
|
||||
|
||||
// Clear the summarized thread and content
|
||||
setSummarizedThread([]);
|
||||
setSummaryContent('');
|
||||
};
|
||||
|
||||
const hasContextHandlerContent = (message: Message): boolean => {
|
||||
return hasContextLengthExceededContent(message) || hasSummarizationRequestedContent(message);
|
||||
};
|
||||
|
||||
const hasContextLengthExceededContent = (message: Message): boolean => {
|
||||
return message.content.some((content) => content.type === 'contextLengthExceeded');
|
||||
};
|
||||
|
||||
const hasSummarizationRequestedContent = (message: Message): boolean => {
|
||||
return message.content.some((content) => content.type === 'summarizationRequested');
|
||||
};
|
||||
|
||||
const getContextHandlerType = (
|
||||
message: Message
|
||||
): 'contextLengthExceeded' | 'summarizationRequested' => {
|
||||
if (hasContextLengthExceededContent(message)) {
|
||||
return 'contextLengthExceeded';
|
||||
}
|
||||
return 'summarizationRequested';
|
||||
};
|
||||
|
||||
const openSummaryModal = () => {
|
||||
setIsSummaryModalOpen(true);
|
||||
};
|
||||
|
||||
const closeSummaryModal = () => {
|
||||
setIsSummaryModalOpen(false);
|
||||
};
|
||||
|
||||
const value = {
|
||||
// State
|
||||
summaryContent,
|
||||
summarizedThread,
|
||||
isSummaryModalOpen,
|
||||
isLoadingCompaction,
|
||||
errorLoadingSummary,
|
||||
preparingManualSummary,
|
||||
isConfirmationOpen,
|
||||
pendingCompactionData,
|
||||
|
||||
// Actions
|
||||
updateSummary,
|
||||
resetMessagesWithSummary,
|
||||
openSummaryModal,
|
||||
closeSummaryModal,
|
||||
hasContextHandlerContent,
|
||||
hasContextLengthExceededContent,
|
||||
hasSummarizationRequestedContent,
|
||||
getContextHandlerType,
|
||||
handleContextLengthExceeded,
|
||||
handleManualCompaction,
|
||||
};
|
||||
|
||||
return (
|
||||
<ChatContextManagerContext.Provider value={value}>
|
||||
{children}
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<Dialog open={isConfirmationOpen} onOpenChange={handleCompactionCancel}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ScrollText className="text-iconStandard" size={24} />
|
||||
Compact Conversation
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will compact your conversation by summarizing the context into a single message
|
||||
and will help you save context space for future interactions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-textStandard">
|
||||
Previous messages will remain visible but only the summary will be included in the
|
||||
active context for Goose. This is useful for long conversations that are approaching
|
||||
the context limit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button type="button" variant="outline" onClick={handleCompactionCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleCompactionConfirm}>
|
||||
Compact Conversation
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ChatContextManagerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Create a hook to use the context
|
||||
export const useChatContextManager = () => {
|
||||
const context = useContext(ChatContextManagerContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useContextManager must be used within a ContextManagerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Message, SummarizationRequestedContent } from '../../types/message';
|
||||
|
||||
interface CompactionMarkerProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export const CompactionMarker: React.FC<CompactionMarkerProps> = ({ message }) => {
|
||||
const compactionContent = message.content.find(
|
||||
(content) => content.type === 'summarizationRequested'
|
||||
) as SummarizationRequestedContent | undefined;
|
||||
|
||||
const markerText = compactionContent?.msg || 'Conversation compacted';
|
||||
|
||||
return <div className="text-xs text-gray-400 py-2 text-left">{markerText}</div>;
|
||||
};
|
||||
@@ -1,242 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Message } from '../../types/message';
|
||||
import { useChatContextManager } from './ChatContextManager';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface ContextHandlerProps {
|
||||
messages: Message[];
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
workingDir: string;
|
||||
contextType: 'contextLengthExceeded' | 'summarizationRequested';
|
||||
onSummaryComplete?: () => void; // Add callback for when summary is complete
|
||||
}
|
||||
|
||||
export const ContextHandler: React.FC<ContextHandlerProps> = ({
|
||||
messages,
|
||||
messageId,
|
||||
chatId,
|
||||
workingDir,
|
||||
contextType,
|
||||
onSummaryComplete,
|
||||
}) => {
|
||||
const {
|
||||
summaryContent,
|
||||
isLoadingCompaction,
|
||||
errorLoadingSummary,
|
||||
openSummaryModal,
|
||||
handleContextLengthExceeded,
|
||||
} = useChatContextManager();
|
||||
const [hasFetchStarted, setHasFetchStarted] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
const isContextLengthExceeded = contextType === 'contextLengthExceeded';
|
||||
|
||||
// Find the relevant message to check if it's the latest
|
||||
const isCurrentMessageLatest =
|
||||
messageId === messages[messages.length - 1]?.id ||
|
||||
messageId === String(messages[messages.length - 1]?.created);
|
||||
|
||||
// Only allow interaction for the most recent context length exceeded event
|
||||
const shouldAllowSummaryInteraction = isCurrentMessageLatest;
|
||||
|
||||
// Use a ref to track if we've started the fetch
|
||||
const fetchStartedRef = useRef(false);
|
||||
const hasCalledSummaryComplete = useRef(false);
|
||||
|
||||
// Call onSummaryComplete when summary is ready
|
||||
useEffect(() => {
|
||||
if (summaryContent && shouldAllowSummaryInteraction && !hasCalledSummaryComplete.current) {
|
||||
hasCalledSummaryComplete.current = true;
|
||||
// Delay the scroll slightly to ensure the content is rendered
|
||||
setTimeout(() => {
|
||||
onSummaryComplete?.();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Reset the flag when summary is cleared
|
||||
if (!summaryContent) {
|
||||
hasCalledSummaryComplete.current = false;
|
||||
}
|
||||
}, [summaryContent, shouldAllowSummaryInteraction, onSummaryComplete]);
|
||||
|
||||
// Scroll when summarization starts (loading state)
|
||||
useEffect(() => {
|
||||
if (isLoadingCompaction && shouldAllowSummaryInteraction) {
|
||||
// Delay the scroll slightly to ensure the loading content is rendered
|
||||
setTimeout(() => {
|
||||
onSummaryComplete?.();
|
||||
}, 100);
|
||||
}
|
||||
}, [isLoadingCompaction, shouldAllowSummaryInteraction, onSummaryComplete]);
|
||||
|
||||
// Function to trigger the async operation properly
|
||||
const triggerContextLengthExceeded = () => {
|
||||
setHasFetchStarted(true);
|
||||
fetchStartedRef.current = true;
|
||||
|
||||
// Call the async function without awaiting it in useEffect
|
||||
handleContextLengthExceeded(messages).catch((err) => {
|
||||
console.error('Error handling context length exceeded:', err);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!summaryContent &&
|
||||
!hasFetchStarted &&
|
||||
shouldAllowSummaryInteraction &&
|
||||
!fetchStartedRef.current
|
||||
) {
|
||||
// Use the wrapper function instead of calling the async function directly
|
||||
triggerContextLengthExceeded();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
hasFetchStarted,
|
||||
messages,
|
||||
shouldAllowSummaryInteraction,
|
||||
summaryContent,
|
||||
chatId,
|
||||
workingDir,
|
||||
]);
|
||||
|
||||
// Handle retry - Call the async function properly
|
||||
const handleRetry = () => {
|
||||
if (!shouldAllowSummaryInteraction) return;
|
||||
|
||||
// Only increment retry counter if there's an error
|
||||
if (errorLoadingSummary) {
|
||||
setRetryCount((prevCount) => prevCount + 1);
|
||||
}
|
||||
|
||||
// Reset states for retry
|
||||
setHasFetchStarted(false);
|
||||
fetchStartedRef.current = false;
|
||||
|
||||
// Trigger the process again
|
||||
triggerContextLengthExceeded();
|
||||
};
|
||||
|
||||
// Function to open a new goose window
|
||||
const openNewSession = () => {
|
||||
try {
|
||||
// Use the workingDir from props directly without reassignment to avoid TypeScript error
|
||||
const sessionWorkingDir = window.appConfig?.get('GOOSE_WORKING_DIR') || workingDir;
|
||||
console.log(`Creating new chat window with working dir: ${sessionWorkingDir}`);
|
||||
window.electron.createChatWindow(undefined, sessionWorkingDir as string);
|
||||
} catch (error) {
|
||||
console.error('Error creating new window:', error);
|
||||
// Fallback to basic window.open if the electron API fails
|
||||
window.open('/', '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
// Render the notification UI
|
||||
const renderLoadingState = () => (
|
||||
<div className="flex items-center text-xs text-gray-400">
|
||||
<span className="mr-2">Preparing summary...</span>
|
||||
<span className="animate-spin h-3 w-3 border-2 border-gray-400 rounded-full border-t-transparent"></span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderFailedState = () => (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isContextLengthExceeded
|
||||
? `Your conversation has exceeded the model's context capacity`
|
||||
: `Summarization requested`}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isContextLengthExceeded
|
||||
? `This conversation has too much information to continue. Extension data often takes up significant space.`
|
||||
: `Summarization failed. Continue chatting or start a new session.`}
|
||||
</span>
|
||||
<Button onClick={openNewSession} className="text-xs transition-colors mt-1 flex items-center">
|
||||
Click here to start a new session
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderRetryState = () => (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isContextLengthExceeded
|
||||
? `Your conversation has exceeded the model's context capacity`
|
||||
: `Summarization requested`}
|
||||
</span>
|
||||
<Button onClick={handleRetry} className="text-xs transition-colors mt-1 flex items-center">
|
||||
Retry loading summary
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderSuccessState = () => (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isContextLengthExceeded
|
||||
? `Your conversation has exceeded the model's context capacity and a summary was prepared.`
|
||||
: `A summary of your conversation was prepared as requested.`}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isContextLengthExceeded
|
||||
? `Messages above this line remain viewable but specific details are not included in active context.`
|
||||
: `This summary includes key points from your conversation.`}
|
||||
</span>
|
||||
{shouldAllowSummaryInteraction && (
|
||||
<Button
|
||||
onClick={openSummaryModal}
|
||||
className="text-xs transition-colors mt-1 flex items-center"
|
||||
>
|
||||
View or edit summary{' '}
|
||||
{isContextLengthExceeded
|
||||
? '(you may continue your conversation based on the summary)'
|
||||
: ''}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Render persistent summarized notification when we shouldn't show interaction options
|
||||
const renderPersistentMarker = () => (
|
||||
<span className="text-xs text-gray-400">
|
||||
Session summarized — messages above this line are not included in the conversation
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderContentState = () => {
|
||||
// If this is not the latest context event message but we have a valid summary,
|
||||
// show the persistent marker
|
||||
if (!shouldAllowSummaryInteraction && summaryContent) {
|
||||
return renderPersistentMarker();
|
||||
}
|
||||
|
||||
// For the latest message with the context event
|
||||
if (shouldAllowSummaryInteraction) {
|
||||
if (errorLoadingSummary) {
|
||||
return retryCount >= 2 ? renderFailedState() : renderRetryState();
|
||||
}
|
||||
|
||||
if (summaryContent) {
|
||||
return renderSuccessState();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to showing at least the persistent marker
|
||||
return renderPersistentMarker();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start mt-1 pl-4">
|
||||
{/* Horizontal line with text in the middle - shown regardless of loading state */}
|
||||
<div className="relative flex items-center py-2 w-full">
|
||||
<div className="flex-grow border-t border-gray-300"></div>
|
||||
<div className="flex-grow border-t border-gray-300"></div>
|
||||
</div>
|
||||
|
||||
{isLoadingCompaction && shouldAllowSummaryInteraction
|
||||
? renderLoadingState()
|
||||
: renderContentState()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { Message } from '../../types/message';
|
||||
import { manageContextFromBackend, convertApiMessageToFrontendMessage } from './index';
|
||||
|
||||
// Define the context management interface
|
||||
interface ContextManagerState {
|
||||
isCompacting: boolean;
|
||||
compactionError: string | null;
|
||||
}
|
||||
|
||||
interface ContextManagerActions {
|
||||
handleAutoCompaction: (
|
||||
messages: Message[],
|
||||
setMessages: (messages: Message[]) => void,
|
||||
append: (message: Message) => void,
|
||||
setAncestorMessages?: (messages: Message[]) => void
|
||||
) => Promise<void>;
|
||||
handleManualCompaction: (
|
||||
messages: Message[],
|
||||
setMessages: (messages: Message[]) => void,
|
||||
append?: (message: Message) => void,
|
||||
setAncestorMessages?: (messages: Message[]) => void
|
||||
) => Promise<void>;
|
||||
hasCompactionMarker: (message: Message) => boolean;
|
||||
}
|
||||
|
||||
// Create the context
|
||||
const ContextManagerContext = createContext<
|
||||
(ContextManagerState & ContextManagerActions) | undefined
|
||||
>(undefined);
|
||||
|
||||
// Create the provider component
|
||||
export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isCompacting, setIsCompacting] = useState<boolean>(false);
|
||||
const [compactionError, setCompactionError] = useState<string | null>(null);
|
||||
|
||||
const performCompaction = useCallback(
|
||||
async (
|
||||
messages: Message[],
|
||||
setMessages: (messages: Message[]) => void,
|
||||
append: (message: Message) => void,
|
||||
isManual: boolean = false,
|
||||
setAncestorMessages?: (messages: Message[]) => void
|
||||
) => {
|
||||
setIsCompacting(true);
|
||||
setCompactionError(null);
|
||||
|
||||
try {
|
||||
// Get the summary from the backend
|
||||
const summaryResponse = await manageContextFromBackend({
|
||||
messages: messages,
|
||||
manageAction: 'summarize',
|
||||
});
|
||||
|
||||
// Convert API messages to frontend messages
|
||||
const convertedMessages = summaryResponse.messages.map((apiMessage) => {
|
||||
const isCompactionMarker = apiMessage.content.some(
|
||||
(content) => content.type === 'summarizationRequested'
|
||||
);
|
||||
|
||||
if (isCompactionMarker) {
|
||||
// show to user but not model
|
||||
return convertApiMessageToFrontendMessage(apiMessage, true, false);
|
||||
}
|
||||
|
||||
// show to model but not user
|
||||
return convertApiMessageToFrontendMessage(apiMessage, false, true);
|
||||
});
|
||||
|
||||
// Store the original messages as ancestor messages so they can still be scrolled to
|
||||
if (setAncestorMessages) {
|
||||
const ancestorMessages = messages.map((msg) => ({
|
||||
...msg,
|
||||
display: msg.display === false ? false : true,
|
||||
sendToLLM: false,
|
||||
}));
|
||||
setAncestorMessages(ancestorMessages);
|
||||
}
|
||||
|
||||
// Replace messages with the server-provided messages
|
||||
setMessages(convertedMessages);
|
||||
|
||||
// Only automatically submit the continuation message for auto-compaction (context limit reached)
|
||||
// Manual compaction should just compact without continuing the conversation
|
||||
if (!isManual) {
|
||||
// Automatically submit the continuation message to continue the conversation
|
||||
// This should be the third message (index 2) which contains the "I ran into a context length exceeded error..." text
|
||||
const continuationMessage = convertedMessages[2];
|
||||
if (continuationMessage) {
|
||||
setTimeout(() => {
|
||||
append(continuationMessage);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
setIsCompacting(false);
|
||||
} catch (err) {
|
||||
console.error('Error during compaction:', err);
|
||||
setCompactionError(err instanceof Error ? err.message : 'Unknown error during compaction');
|
||||
|
||||
// Create an error marker
|
||||
const errorMarker: Message = {
|
||||
id: `compaction-error-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
content: [
|
||||
{
|
||||
type: 'summarizationRequested',
|
||||
msg: 'Compaction failed. Please try again or start a new session.',
|
||||
},
|
||||
],
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
};
|
||||
|
||||
setMessages([...messages, errorMarker]);
|
||||
setIsCompacting(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleAutoCompaction = useCallback(
|
||||
async (
|
||||
messages: Message[],
|
||||
setMessages: (messages: Message[]) => void,
|
||||
append: (message: Message) => void,
|
||||
setAncestorMessages?: (messages: Message[]) => void
|
||||
) => {
|
||||
await performCompaction(messages, setMessages, append, false, setAncestorMessages);
|
||||
},
|
||||
[performCompaction]
|
||||
);
|
||||
|
||||
const handleManualCompaction = useCallback(
|
||||
async (
|
||||
messages: Message[],
|
||||
setMessages: (messages: Message[]) => void,
|
||||
append?: (message: Message) => void,
|
||||
setAncestorMessages?: (messages: Message[]) => void
|
||||
) => {
|
||||
await performCompaction(
|
||||
messages,
|
||||
setMessages,
|
||||
append || (() => {}),
|
||||
true,
|
||||
setAncestorMessages
|
||||
);
|
||||
},
|
||||
[performCompaction]
|
||||
);
|
||||
|
||||
const hasCompactionMarker = useCallback((message: Message): boolean => {
|
||||
return message.content.some((content) => content.type === 'summarizationRequested');
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
// State
|
||||
isCompacting,
|
||||
compactionError,
|
||||
|
||||
// Actions
|
||||
handleAutoCompaction,
|
||||
handleManualCompaction,
|
||||
hasCompactionMarker,
|
||||
};
|
||||
|
||||
return <ContextManagerContext.Provider value={value}>{children}</ContextManagerContext.Provider>;
|
||||
};
|
||||
|
||||
// Create a hook to use the context
|
||||
export const useContextManager = () => {
|
||||
const context = useContext(ContextManagerContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useContextManager must be used within a ContextManagerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ScrollText } from 'lucide-react';
|
||||
import { cn } from '../../utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
|
||||
import { useChatContextManager } from './ChatContextManager';
|
||||
import { Message } from '../../types/message';
|
||||
|
||||
interface ManualCompactButtonProps {
|
||||
messages: Message[];
|
||||
isLoading?: boolean; // need this prop to know if Goose is responding
|
||||
setMessages: (messages: Message[]) => void; // context management is triggered via special message content types
|
||||
}
|
||||
|
||||
export const ManualCompactButton: React.FC<ManualCompactButtonProps> = ({
|
||||
messages,
|
||||
isLoading = false,
|
||||
setMessages,
|
||||
}) => {
|
||||
const { handleManualCompaction, isLoadingCompaction } = useChatContextManager();
|
||||
|
||||
const [isConfirmationOpen, setIsConfirmationOpen] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsConfirmationOpen(true);
|
||||
};
|
||||
|
||||
const handleCompaction = async () => {
|
||||
setIsConfirmationOpen(false);
|
||||
|
||||
try {
|
||||
handleManualCompaction(messages, setMessages);
|
||||
} catch (error) {
|
||||
console.error('Error in handleCompaction:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsConfirmationOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-px h-4 bg-border-default mx-2" />
|
||||
<div className="relative flex items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center justify-center text-text-default/70 hover:text-text-default text-xs cursor-pointer transition-colors',
|
||||
(isLoadingCompaction || isLoading) &&
|
||||
'cursor-not-allowed text-text-default/30 hover:text-text-default/30 opacity-50'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
disabled={isLoadingCompaction || isLoading}
|
||||
>
|
||||
<ScrollText size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isLoadingCompaction ? 'Compacting conversation...' : 'Compact conversation context'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Summarization Confirmation Modal */}
|
||||
<Dialog open={isConfirmationOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ScrollText className="text-iconStandard" size={24} />
|
||||
Compact Conversation
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will compact your conversation by summarizing the context into a single message
|
||||
and will help you save context space for future interactions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-textStandard">
|
||||
Previous messages will remain visible but only the summary will be included in the
|
||||
active context for Goose. This is useful for long conversations that are approaching
|
||||
the context limit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleCompaction}>
|
||||
Compact Conversation
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { Geese } from '../icons/Geese';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
|
||||
interface SessionSummaryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (editedContent: string) => void;
|
||||
summaryContent: string;
|
||||
}
|
||||
|
||||
export function SessionSummaryModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
summaryContent,
|
||||
}: SessionSummaryModalProps) {
|
||||
// Use a ref for the textarea for uncontrolled component
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Initialize the textarea value when the modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && textareaRef.current) {
|
||||
textareaRef.current.value = summaryContent;
|
||||
}
|
||||
}, [isOpen, summaryContent]);
|
||||
|
||||
// Handle Save action with the edited content from the ref
|
||||
const handleSave = () => {
|
||||
const currentText = textareaRef.current ? textareaRef.current.value : '';
|
||||
onSave(currentText);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex flex-col items-center text-center">
|
||||
<div className="mb-4">
|
||||
<Geese width="48" height="50" />
|
||||
</div>
|
||||
Session Summary
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center max-w-md m-auto">
|
||||
This summary was created to manage your context limit. Review and edit to keep your
|
||||
session running smoothly with the information that matters most.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<div className="w-full">
|
||||
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-3">
|
||||
Summarization
|
||||
</h3>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
defaultValue={summaryContent}
|
||||
className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 text-sm w-full min-h-[200px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
style={{
|
||||
textRendering: 'optimizeLegibility',
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
MozOsxFontSmoothing: 'grayscale',
|
||||
transform: 'translateZ(0)', // Force hardware acceleration
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save and Continue</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { CompactionMarker } from '../CompactionMarker';
|
||||
import { Message } from '../../../types/message';
|
||||
|
||||
describe('CompactionMarker', () => {
|
||||
it('should render default message when no summarizationRequested content found', () => {
|
||||
const message: Message = {
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
created: 1000,
|
||||
content: [{ type: 'text', text: 'Regular message' }],
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
};
|
||||
|
||||
render(<CompactionMarker message={message} />);
|
||||
|
||||
expect(screen.getByText('Conversation compacted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom message from summarizationRequested content', () => {
|
||||
const message: Message = {
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
created: 1000,
|
||||
content: [
|
||||
{ type: 'text', text: 'Some other content' },
|
||||
{ type: 'summarizationRequested', msg: 'Custom compaction message' },
|
||||
],
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
};
|
||||
|
||||
render(<CompactionMarker message={message} />);
|
||||
|
||||
expect(screen.getByText('Custom compaction message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty message content array', () => {
|
||||
const message: Message = {
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
created: 1000,
|
||||
content: [],
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
};
|
||||
|
||||
render(<CompactionMarker message={message} />);
|
||||
|
||||
expect(screen.getByText('Conversation compacted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle summarizationRequested content with empty msg', () => {
|
||||
const message: Message = {
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
created: 1000,
|
||||
content: [{ type: 'summarizationRequested', msg: '' }],
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
};
|
||||
|
||||
render(<CompactionMarker message={message} />);
|
||||
|
||||
// Empty string falls back to default due to || operator
|
||||
expect(screen.getByText('Conversation compacted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle summarizationRequested content with undefined msg', () => {
|
||||
const message: Message = {
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
created: 1000,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
content: [{ type: 'summarizationRequested' } as any],
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
};
|
||||
|
||||
render(<CompactionMarker message={message} />);
|
||||
|
||||
// Should render the default message when msg is undefined
|
||||
expect(screen.getByText('Conversation compacted')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,661 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { ContextManagerProvider, useContextManager } from '../ContextManager';
|
||||
import { Message } from '../../../types/message';
|
||||
import * as contextManagement from '../index';
|
||||
import { ContextManageResponse } from '../../../api';
|
||||
|
||||
// Mock the context management functions
|
||||
vi.mock('../index', () => ({
|
||||
manageContextFromBackend: vi.fn(),
|
||||
convertApiMessageToFrontendMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockManageContextFromBackend = vi.mocked(contextManagement.manageContextFromBackend);
|
||||
const mockConvertApiMessageToFrontendMessage = vi.mocked(
|
||||
contextManagement.convertApiMessageToFrontendMessage
|
||||
);
|
||||
|
||||
describe('ContextManager', () => {
|
||||
const mockMessages: Message[] = [
|
||||
{
|
||||
id: '1',
|
||||
role: 'user',
|
||||
created: 1000,
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
display: true,
|
||||
sendToLLM: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: 'assistant',
|
||||
created: 2000,
|
||||
content: [{ type: 'text', text: 'Hi there!' }],
|
||||
display: true,
|
||||
sendToLLM: true,
|
||||
},
|
||||
];
|
||||
|
||||
const mockSummaryMessage: Message = {
|
||||
id: 'summary-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [{ type: 'text', text: 'This is a summary of the conversation.' }],
|
||||
display: false,
|
||||
sendToLLM: true,
|
||||
};
|
||||
|
||||
const mockSetMessages = vi.fn();
|
||||
const mockAppend = vi.fn();
|
||||
const mockSetAncestorMessages = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const renderContextManager = () => {
|
||||
return renderHook(() => useContextManager(), {
|
||||
wrapper: ({ children }) => <ContextManagerProvider>{children}</ContextManagerProvider>,
|
||||
});
|
||||
};
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should have correct initial state', () => {
|
||||
const { result } = renderContextManager();
|
||||
|
||||
expect(result.current.isCompacting).toBe(false);
|
||||
expect(result.current.compactionError).toBe(null);
|
||||
expect(typeof result.current.handleAutoCompaction).toBe('function');
|
||||
expect(typeof result.current.handleManualCompaction).toBe('function');
|
||||
expect(typeof result.current.hasCompactionMarker).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasCompactionMarker', () => {
|
||||
it('should return true for messages with summarizationRequested content', () => {
|
||||
const { result } = renderContextManager();
|
||||
const messageWithMarker: Message = {
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
created: 1000,
|
||||
content: [{ type: 'summarizationRequested', msg: 'Compaction marker' }],
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
};
|
||||
|
||||
expect(result.current.hasCompactionMarker(messageWithMarker)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for messages without summarizationRequested content', () => {
|
||||
const { result } = renderContextManager();
|
||||
const regularMessage: Message = {
|
||||
id: '1',
|
||||
role: 'user',
|
||||
created: 1000,
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
display: true,
|
||||
sendToLLM: true,
|
||||
};
|
||||
|
||||
expect(result.current.hasCompactionMarker(regularMessage)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for messages with mixed content including summarizationRequested', () => {
|
||||
const { result } = renderContextManager();
|
||||
const mixedMessage: Message = {
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
created: 1000,
|
||||
content: [
|
||||
{ type: 'text', text: 'Some text' },
|
||||
{ type: 'summarizationRequested', msg: 'Compaction marker' },
|
||||
],
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
};
|
||||
|
||||
expect(result.current.hasCompactionMarker(mixedMessage)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAutoCompaction', () => {
|
||||
it('should successfully perform auto compaction with server-provided messages', async () => {
|
||||
// Mock the backend response with 3 messages: marker, summary, continuation
|
||||
mockManageContextFromBackend.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Summary content' }],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tokenCounts: [8, 100, 50],
|
||||
});
|
||||
|
||||
const mockCompactionMarker: Message = {
|
||||
id: 'marker-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
};
|
||||
|
||||
const mockContinuationMessage: Message = {
|
||||
id: 'continuation-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
display: false,
|
||||
sendToLLM: true,
|
||||
};
|
||||
|
||||
// Mock the conversion function to return different messages based on call order
|
||||
mockConvertApiMessageToFrontendMessage
|
||||
.mockReturnValueOnce(mockCompactionMarker) // First call - compaction marker (display: true, sendToLLM: false)
|
||||
.mockReturnValueOnce(mockSummaryMessage) // Second call - summary (display: false, sendToLLM: true)
|
||||
.mockReturnValueOnce(mockContinuationMessage); // Third call - continuation (display: false, sendToLLM: true)
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAutoCompaction(
|
||||
mockMessages,
|
||||
mockSetMessages,
|
||||
mockAppend,
|
||||
mockSetAncestorMessages
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockManageContextFromBackend).toHaveBeenCalledWith({
|
||||
messages: mockMessages,
|
||||
manageAction: 'summarize',
|
||||
});
|
||||
|
||||
// Verify conversion calls with correct parameters
|
||||
expect(mockConvertApiMessageToFrontendMessage).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
content: [
|
||||
{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' },
|
||||
],
|
||||
}),
|
||||
true, // display: true
|
||||
false // sendToLLM: false
|
||||
);
|
||||
expect(mockConvertApiMessageToFrontendMessage).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
content: [{ type: 'text', text: 'Summary content' }],
|
||||
}),
|
||||
false, // display: false
|
||||
true // sendToLLM: true
|
||||
);
|
||||
expect(mockConvertApiMessageToFrontendMessage).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: expect.stringContaining('The previous message contains a summary'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
false, // display: false
|
||||
true // sendToLLM: true
|
||||
);
|
||||
|
||||
expect(mockSetAncestorMessages).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: '1',
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: '2',
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
// Expect setMessages to be called with all 3 converted messages
|
||||
expect(mockSetMessages).toHaveBeenCalledWith([
|
||||
mockCompactionMarker,
|
||||
mockSummaryMessage,
|
||||
mockContinuationMessage,
|
||||
]);
|
||||
|
||||
// Fast-forward timers to trigger the append call
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
// Should append the continuation message (index 2) for auto-compaction
|
||||
expect(mockAppend).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppend).toHaveBeenCalledWith(mockContinuationMessage);
|
||||
});
|
||||
|
||||
it('should handle compaction errors gracefully', async () => {
|
||||
const error = new Error('Backend error');
|
||||
mockManageContextFromBackend.mockRejectedValue(error);
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAutoCompaction(
|
||||
mockMessages,
|
||||
mockSetMessages,
|
||||
mockAppend,
|
||||
mockSetAncestorMessages
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.compactionError).toBe('Backend error');
|
||||
expect(result.current.isCompacting).toBe(false);
|
||||
|
||||
expect(mockSetMessages).toHaveBeenCalledWith([
|
||||
...mockMessages,
|
||||
expect.objectContaining({
|
||||
content: [
|
||||
{
|
||||
type: 'summarizationRequested',
|
||||
msg: 'Compaction failed. Please try again or start a new session.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should set isCompacting state correctly during operation', async () => {
|
||||
let resolvePromise: (value: ContextManageResponse) => void;
|
||||
const promise = new Promise<ContextManageResponse>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
mockManageContextFromBackend.mockReturnValue(promise);
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
// Start compaction
|
||||
act(() => {
|
||||
result.current.handleAutoCompaction(
|
||||
mockMessages,
|
||||
mockSetMessages,
|
||||
mockAppend,
|
||||
mockSetAncestorMessages
|
||||
);
|
||||
});
|
||||
|
||||
// Should be compacting
|
||||
expect(result.current.isCompacting).toBe(true);
|
||||
expect(result.current.compactionError).toBe(null);
|
||||
|
||||
// Resolve the backend call
|
||||
resolvePromise!({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Summary content' }],
|
||||
},
|
||||
],
|
||||
tokenCounts: [100, 50],
|
||||
});
|
||||
|
||||
mockConvertApiMessageToFrontendMessage.mockReturnValue(mockSummaryMessage);
|
||||
|
||||
await act(async () => {
|
||||
await promise;
|
||||
});
|
||||
|
||||
// Should no longer be compacting
|
||||
expect(result.current.isCompacting).toBe(false);
|
||||
});
|
||||
it('preserves display: false for ancestor messages', async () => {
|
||||
// Backend returns no new messages; we're validating ancestor behavior only
|
||||
mockManageContextFromBackend.mockResolvedValue({ messages: [], tokenCounts: [] });
|
||||
|
||||
const hiddenMessage: Message = {
|
||||
id: 'hidden-1',
|
||||
role: 'user',
|
||||
created: 1500,
|
||||
content: [{ type: 'text', text: 'Secret' }],
|
||||
display: false,
|
||||
sendToLLM: true,
|
||||
};
|
||||
|
||||
const visibleMessage: Message = {
|
||||
id: 'visible-1',
|
||||
role: 'assistant',
|
||||
created: 1600,
|
||||
content: [{ type: 'text', text: 'Public' }],
|
||||
display: true,
|
||||
sendToLLM: true,
|
||||
};
|
||||
|
||||
const messages: Message[] = [hiddenMessage, visibleMessage];
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAutoCompaction(
|
||||
messages,
|
||||
mockSetMessages,
|
||||
mockAppend,
|
||||
mockSetAncestorMessages
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockSetAncestorMessages).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'hidden-1', display: false, sendToLLM: false }),
|
||||
expect.objectContaining({ id: 'visible-1', display: true, sendToLLM: false }),
|
||||
])
|
||||
);
|
||||
|
||||
// No server messages -> setMessages called with empty list
|
||||
expect(mockSetMessages).toHaveBeenCalledWith([]);
|
||||
expect(mockAppend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleManualCompaction', () => {
|
||||
it('should perform compaction with server-provided messages', async () => {
|
||||
mockManageContextFromBackend.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Manual summary content' }],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tokenCounts: [8, 100, 50],
|
||||
});
|
||||
|
||||
const mockCompactionMarker: Message = {
|
||||
id: 'marker-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
};
|
||||
|
||||
const mockContinuationMessage: Message = {
|
||||
id: 'continuation-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
display: false,
|
||||
sendToLLM: true,
|
||||
};
|
||||
|
||||
mockConvertApiMessageToFrontendMessage
|
||||
.mockReturnValueOnce(mockCompactionMarker)
|
||||
.mockReturnValueOnce(mockSummaryMessage)
|
||||
.mockReturnValueOnce(mockContinuationMessage);
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleManualCompaction(
|
||||
mockMessages,
|
||||
mockSetMessages,
|
||||
mockAppend,
|
||||
mockSetAncestorMessages
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockManageContextFromBackend).toHaveBeenCalledWith({
|
||||
messages: mockMessages,
|
||||
manageAction: 'summarize',
|
||||
});
|
||||
|
||||
// Verify all three messages are set
|
||||
expect(mockSetMessages).toHaveBeenCalledWith([
|
||||
mockCompactionMarker,
|
||||
mockSummaryMessage,
|
||||
mockContinuationMessage,
|
||||
]);
|
||||
|
||||
// Fast-forward timers to check if append would be called
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
// Should NOT append the continuation message for manual compaction
|
||||
expect(mockAppend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should work without append function', async () => {
|
||||
mockManageContextFromBackend.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Manual summary content' }],
|
||||
},
|
||||
],
|
||||
tokenCounts: [100, 50],
|
||||
});
|
||||
|
||||
mockConvertApiMessageToFrontendMessage.mockReturnValue(mockSummaryMessage);
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleManualCompaction(
|
||||
mockMessages,
|
||||
mockSetMessages,
|
||||
undefined, // No append function
|
||||
mockSetAncestorMessages
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockManageContextFromBackend).toHaveBeenCalled();
|
||||
// Should not throw error when append is undefined
|
||||
|
||||
// Fast-forward timers to check if append would be called
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
// No append function provided, so no calls should be made
|
||||
expect(mockAppend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not auto-continue conversation for manual compaction even with append function', async () => {
|
||||
mockManageContextFromBackend.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Manual summary content' }],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tokenCounts: [8, 100, 50],
|
||||
});
|
||||
|
||||
const mockCompactionMarker: Message = {
|
||||
id: 'marker-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
};
|
||||
|
||||
const mockContinuationMessage: Message = {
|
||||
id: 'continuation-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
display: false,
|
||||
sendToLLM: true,
|
||||
};
|
||||
|
||||
mockConvertApiMessageToFrontendMessage
|
||||
.mockReturnValueOnce(mockCompactionMarker)
|
||||
.mockReturnValueOnce(mockSummaryMessage)
|
||||
.mockReturnValueOnce(mockContinuationMessage);
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleManualCompaction(
|
||||
mockMessages,
|
||||
mockSetMessages,
|
||||
mockAppend, // Provide append function
|
||||
mockSetAncestorMessages
|
||||
);
|
||||
});
|
||||
|
||||
// Verify all three messages are set
|
||||
expect(mockSetMessages).toHaveBeenCalledWith([
|
||||
mockCompactionMarker,
|
||||
mockSummaryMessage,
|
||||
mockContinuationMessage,
|
||||
]);
|
||||
|
||||
// Fast-forward timers to check if append would be called
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
// Should NOT auto-continue for manual compaction, even with append function
|
||||
expect(mockAppend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle backend errors with unknown error type', async () => {
|
||||
mockManageContextFromBackend.mockRejectedValue('String error');
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAutoCompaction(
|
||||
mockMessages,
|
||||
mockSetMessages,
|
||||
mockAppend,
|
||||
mockSetAncestorMessages
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.compactionError).toBe('Unknown error during compaction');
|
||||
});
|
||||
|
||||
it('should handle missing summary content gracefully with server-provided messages', async () => {
|
||||
mockManageContextFromBackend.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'toolResponse', id: 'test', toolResult: { content: 'Not text content' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
tokenCounts: [100, 50],
|
||||
});
|
||||
|
||||
const mockMessageWithoutText: Message = {
|
||||
id: 'summary-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [{ type: 'toolResponse', id: 'test', toolResult: { status: 'success' } }],
|
||||
display: false,
|
||||
sendToLLM: true,
|
||||
};
|
||||
|
||||
mockConvertApiMessageToFrontendMessage.mockReturnValue(mockMessageWithoutText);
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAutoCompaction(
|
||||
mockMessages,
|
||||
mockSetMessages,
|
||||
mockAppend,
|
||||
mockSetAncestorMessages
|
||||
);
|
||||
});
|
||||
|
||||
// Should complete without error even if content is not text
|
||||
expect(result.current.isCompacting).toBe(false);
|
||||
expect(result.current.compactionError).toBe(null);
|
||||
|
||||
// Should still set messages with the converted message
|
||||
expect(mockSetMessages).toHaveBeenCalledWith([mockMessageWithoutText]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Context Provider Error', () => {
|
||||
it('should throw error when useContextManager is used outside provider', () => {
|
||||
expect(() => {
|
||||
renderHook(() => useContextManager());
|
||||
}).toThrow('useContextManager must be used within a ContextManagerProvider');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import { SessionInsights } from './sessions/SessionsInsights';
|
||||
import ChatInput from './ChatInput';
|
||||
import { generateSessionId } from '../sessions';
|
||||
import { ChatState } from '../types/chatState';
|
||||
import { ChatContextManagerProvider } from './context_management/ChatContextManager';
|
||||
import { ContextManagerProvider } from './context_management/ContextManager';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
import { ChatType } from '../types/chat';
|
||||
@@ -77,7 +77,7 @@ export default function Hub({
|
||||
};
|
||||
|
||||
return (
|
||||
<ChatContextManagerProvider>
|
||||
<ContextManagerProvider>
|
||||
<div className="flex flex-col h-full bg-background-muted">
|
||||
<div className="flex-1 flex flex-col mb-0.5">
|
||||
<SessionInsights />
|
||||
@@ -103,6 +103,6 @@ export default function Hub({
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
/>
|
||||
</div>
|
||||
</ChatContextManagerProvider>
|
||||
</ContextManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { ScrollArea } from '../ui/scroll-area';
|
||||
import { formatMessageTimestamp } from '../../utils/timeUtils';
|
||||
import ProgressiveMessageList from '../ProgressiveMessageList';
|
||||
import { SearchView } from '../conversation/SearchView';
|
||||
import { ChatContextManagerProvider } from '../context_management/ChatContextManager';
|
||||
import { ContextManagerProvider } from '../context_management/ContextManager';
|
||||
import { Message } from '../../types/message';
|
||||
import BackButton from '../ui/BackButton';
|
||||
|
||||
@@ -93,7 +93,7 @@ const SessionMessages: React.FC<{
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredMessages?.length > 0 ? (
|
||||
<ChatContextManagerProvider>
|
||||
<ContextManagerProvider>
|
||||
<div className="max-w-4xl mx-auto w-full">
|
||||
<SearchView>
|
||||
<ProgressiveMessageList
|
||||
@@ -115,7 +115,7 @@ const SessionMessages: React.FC<{
|
||||
/>
|
||||
</SearchView>
|
||||
</div>
|
||||
</ChatContextManagerProvider>
|
||||
</ContextManagerProvider>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
|
||||
<MessageSquareText className="w-12 h-12 mb-4" />
|
||||
|
||||
@@ -347,40 +347,8 @@ export function useMessageStream({
|
||||
}
|
||||
|
||||
case 'Error': {
|
||||
// Check if this is a token limit error (more specific detection)
|
||||
const errorMessage = parsedEvent.error;
|
||||
const isTokenLimitError =
|
||||
errorMessage &&
|
||||
((errorMessage.toLowerCase().includes('token') &&
|
||||
errorMessage.toLowerCase().includes('limit')) ||
|
||||
(errorMessage.toLowerCase().includes('context') &&
|
||||
errorMessage.toLowerCase().includes('length') &&
|
||||
errorMessage.toLowerCase().includes('exceeded')));
|
||||
|
||||
// If this is a token limit error, create a contextLengthExceeded message instead of throwing
|
||||
if (isTokenLimitError) {
|
||||
const contextMessage: Message = {
|
||||
id: generateMessageId(),
|
||||
role: 'assistant',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
content: [
|
||||
{
|
||||
type: 'contextLengthExceeded',
|
||||
msg: errorMessage,
|
||||
},
|
||||
],
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
};
|
||||
|
||||
currentMessages = [...currentMessages, contextMessage];
|
||||
mutate(currentMessages, false);
|
||||
|
||||
// Clear any existing error state since we handled this as a context message
|
||||
setError(undefined);
|
||||
break; // Don't throw error, just add the message
|
||||
}
|
||||
|
||||
// Always throw the error so it gets caught and sets the error state
|
||||
// This ensures the retry UI appears for ALL errors
|
||||
throw new Error(parsedEvent.error);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { generateSessionId } from '../sessions';
|
||||
import { ChatType } from '../types/chat';
|
||||
|
||||
interface UseSessionContinuationProps {
|
||||
chat: ChatType;
|
||||
setChat: (chat: ChatType) => void;
|
||||
summarizedThread: unknown[];
|
||||
updateMessageStreamBody?: (body: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export const useSessionContinuation = ({
|
||||
chat,
|
||||
setChat,
|
||||
summarizedThread,
|
||||
updateMessageStreamBody,
|
||||
}: UseSessionContinuationProps) => {
|
||||
// Track if we've already created a new session to prevent multiple calls
|
||||
const hasCreatedNewSession = useRef(false);
|
||||
// Track the previous summarized thread length to detect when it gets cleared
|
||||
const prevSummarizedThreadLength = useRef(0);
|
||||
// Flag to indicate we should create a new session after the next message is sent
|
||||
const shouldCreateNewSessionAfterMessage = useRef(false);
|
||||
|
||||
// Handle session continuation when there's a summarized thread
|
||||
useEffect(() => {
|
||||
// Detect when summarizedThread goes from having content to being empty
|
||||
// This indicates the summary process is complete and messages have been reset
|
||||
const wasCleared = prevSummarizedThreadLength.current > 0 && summarizedThread.length === 0;
|
||||
|
||||
// When the thread is cleared, mark that we should create a new session
|
||||
// but don't do it immediately - wait for the message to be sent first
|
||||
if (wasCleared && !hasCreatedNewSession.current) {
|
||||
shouldCreateNewSessionAfterMessage.current = true;
|
||||
}
|
||||
|
||||
// Update the previous length for next comparison
|
||||
prevSummarizedThreadLength.current = summarizedThread.length;
|
||||
|
||||
// Reset the flag when we start a completely new session (no summarized thread at all)
|
||||
if (summarizedThread.length === 0 && prevSummarizedThreadLength.current === 0) {
|
||||
hasCreatedNewSession.current = false;
|
||||
shouldCreateNewSessionAfterMessage.current = false;
|
||||
}
|
||||
}, [summarizedThread.length]);
|
||||
|
||||
// Function to be called after a message is successfully sent
|
||||
const createNewSessionIfNeeded = () => {
|
||||
if (shouldCreateNewSessionAfterMessage.current && !hasCreatedNewSession.current) {
|
||||
const newSessionId = generateSessionId();
|
||||
|
||||
// Mark that we've created a new session
|
||||
hasCreatedNewSession.current = true;
|
||||
shouldCreateNewSessionAfterMessage.current = false;
|
||||
|
||||
console.log('Creating new session, preserving recipe config:', {
|
||||
oldSessionId: chat.id,
|
||||
newSessionId,
|
||||
currentRecipeConfig: chat.recipeConfig,
|
||||
recipeTitle: chat.recipeConfig?.title,
|
||||
});
|
||||
|
||||
// Update the session ID in the chat object while preserving recipe config
|
||||
setChat({
|
||||
...chat,
|
||||
id: newSessionId!,
|
||||
title: `Continued from ${chat.id}`,
|
||||
messageHistoryIndex: 0, // Reset since messages were already reset
|
||||
// Explicitly preserve the recipe config
|
||||
recipeConfig: chat.recipeConfig,
|
||||
});
|
||||
|
||||
// Update the body used by useMessageStream to send future messages to the new session
|
||||
if (updateMessageStreamBody) {
|
||||
updateMessageStreamBody({
|
||||
session_id: newSessionId,
|
||||
session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { createNewSessionIfNeeded };
|
||||
};
|
||||
Reference in New Issue
Block a user