Goose Simple Compact UX (#4202)

Co-authored-by: David Katz <dkatz@squareup.com>
This commit is contained in:
Alex Hancock
2025-08-26 18:12:02 -04:00
committed by GitHub
parent a3097f5250
commit a4fc5ec4f1
27 changed files with 2608 additions and 1127 deletions
+52 -126
View File
@@ -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>
);
}
+28 -53
View File
@@ -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 -5
View File
@@ -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);
});
});
});
+4 -3
View File
@@ -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');
});
});
});
+3 -3
View File
@@ -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" />
+2 -34
View File
@@ -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 };
};